Compare commits

..
1 Commits
Author SHA1 Message Date
Petar Petrov 2e95634e77 Cache resolved media source URLs for view backgrounds 2026-08-12 15:10:16 +03:00
161 changed files with 1828 additions and 7180 deletions
-4
View File
@@ -11,9 +11,6 @@ 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
@@ -24,4 +21,3 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
RSPACK_CACHE: ${{ inputs.rspack-cache }}
+4 -71
View File
@@ -97,12 +97,10 @@ jobs:
run: yarn run test
build:
name: Build frontend
# 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
needs:
- prepare-dependencies
- lint
- test
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -113,26 +111,12 @@ 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:
@@ -148,54 +132,3 @@ 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
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"
-1
View File
@@ -137,7 +137,6 @@ 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
+1 -24
View File
@@ -58,24 +58,9 @@ 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
@@ -84,7 +69,7 @@ jobs:
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
# Not gated on the restore steps: a transient restore failure (they are
# Not gated on the restore step: a transient restore failure (it is
# continue-on-error) must not stop us persisting a freshly built cache.
- name: Save compression cache
if: success()
@@ -94,14 +79,6 @@ jobs:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Save rspack cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
-1
View File
@@ -7,7 +7,6 @@ dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 585518,
"core": 57048,
"authorize": 552423,
"onboarding": 666818
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
},
"frontend-legacy": {
"app": 887384,
"core": 244482,
"authorize": 844995,
"onboarding": 1008816
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
}
}
+1 -2
View File
@@ -277,7 +277,7 @@ module.exports.config = {
};
},
gallery({ isProdBuild, latestBuild, isTestBuild }) {
gallery({ isProdBuild, latestBuild }) {
return {
name: "gallery" + nameSuffix(latestBuild),
entry: {
@@ -287,7 +287,6 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isTestBuild,
defineOverlay: {
__DEMO__: true,
},
-1
View File
@@ -252,7 +252,6 @@ gulp.task("rspack-prod-gallery", () =>
createGalleryConfig({
isProdBuild: true,
latestBuild: true,
isTestBuild: env.isTestBuild(),
})
)
);
+3 -89
View File
@@ -1,6 +1,4 @@
const fs = require("fs");
const { existsSync } = fs;
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -18,61 +16,6 @@ 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;
@@ -433,33 +376,6 @@ 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,
},
@@ -489,10 +405,8 @@ const createDemoConfig = ({
const createCastConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
createRspackConfig(
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
);
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
-36
View File
@@ -14,7 +14,6 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
@@ -51,39 +50,4 @@ 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",
},
];
-5
View File
@@ -102,11 +102,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
-6
View File
@@ -1,12 +1,6 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS(
"sensor/device_class_convertible_units",
({ device_class }: { device_class: string }) => ({
units: device_class === "energy" ? ["kWh"] : ["W"],
})
);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
-3
View File
@@ -87,7 +87,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -112,7 +111,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -137,7 +135,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -50,7 +50,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -75,7 +74,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -100,7 +100,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -125,7 +124,6 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -150,85 +148,6 @@ 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",
},
];
+1 -79
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,84 +340,6 @@ 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,7 +238,6 @@ const createDeviceRegistryEntries = (
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
+11 -1
View File
@@ -15,6 +15,7 @@ const ALL_FEATURES =
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
@@ -27,6 +28,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
@@ -38,6 +41,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
@@ -53,7 +58,10 @@ const ENTITIES = [
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME,
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
status: "Returning to dock",
},
},
@@ -88,6 +96,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
+6 -5
View File
@@ -103,7 +103,7 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"hls.js": "1.6.17",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
@@ -151,8 +151,8 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rspack/core": "2.1.10",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.11.0",
"globals": "17.9.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.66.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -223,6 +223,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
-19
View File
@@ -39,25 +39,6 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
@@ -2,31 +2,10 @@ 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"],
// Required so every caller resolves a child device's effective area
// consistently, see getDeviceAreaId.
devices: HomeAssistant["devices"]
areas: HomeAssistant["areas"]
): AreaRegistryEntry | undefined => {
const areaId = getDeviceAreaId(device, devices);
const areaId = device.area_id;
return areaId ? areas[areaId] : undefined;
};
@@ -8,7 +8,6 @@ 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;
@@ -47,11 +46,7 @@ export const getEntityAreaId = (
if (!entry) return undefined;
const deviceId = entry.device_id;
const device = deviceId ? devices[deviceId] : undefined;
return (
entry.area_id ||
(device ? getDeviceAreaId(device, devices) : undefined) ||
undefined
);
return entry.area_id || device?.area_id || undefined;
};
export const getEntityEntryContext = (
@@ -65,8 +60,7 @@ 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 ? getDeviceAreaId(device, devices) : undefined);
const areaId = entry?.area_id || device?.area_id;
const area = areaId ? areas[areaId] : undefined;
const floorId = area?.floor_id;
const floor = floorId ? floors[floorId] : undefined;
-64
View File
@@ -1,64 +0,0 @@
import {
isMoreInfoView,
type MoreInfoView,
} from "../../dialogs/more-info/more-info-view";
import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
? new URLSearchParams(search)
: search instanceof URLSearchParams
? search
: new URLSearchParams(search);
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
const view = params.get(VIEW_PARAM) || undefined;
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
export const createMoreInfoUrl = (
base: string,
data: CreateMoreInfoUrlData
): string => {
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
url.searchParams.delete(VIEW_PARAM);
return `${url.pathname}${url.search}${url.hash}`;
};
+15 -17
View File
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@@ -1158,11 +1158,6 @@ export class HaDataTable extends LitElement {
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
--_cell-padding-inline: 16px;
}
:host([narrow]) {
--_cell-padding-inline: 8px;
}
.mdc-data-table__content {
font-family: var(--ha-font-family-body);
@@ -1243,7 +1238,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-inline: var(--_cell-padding-inline);
padding-right: 16px;
padding-left: 16px;
min-width: 150px;
align-self: center;
overflow: hidden;
@@ -1263,8 +1259,14 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--checkbox,
.mdc-data-table__cell--checkbox {
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: 0;
/* @noflip */
padding-left: 16px;
/* @noflip */
padding-right: 0;
/* @noflip */
padding-inline-start: 16px;
/* @noflip */
padding-inline-end: initial;
width: 60px;
min-width: 60px;
}
@@ -1377,7 +1379,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:first-child,
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
padding-inline-start: var(--_cell-padding-inline);
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-end: initial;
}
@@ -1385,7 +1388,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:last-child,
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
padding-inline-end: var(--_cell-padding-inline);
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
@@ -1512,17 +1516,11 @@ export class HaDataTable extends LitElement {
.center {
text-align: center;
}
.primary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.scroller {
height: calc(100% - 57px);
-1
View File
@@ -8,7 +8,6 @@ export const datePickerStyles = css`
}
calendar-date::part(button),
calendar-range::part(button) {
color: var(--primary-text-color);
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
@@ -73,7 +73,7 @@ export class DialogDeviceReplaced
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas, devices) : undefined;
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
+39 -104
View File
@@ -3,13 +3,11 @@ 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,
@@ -28,9 +26,7 @@ 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 = (
@@ -132,7 +128,6 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -221,34 +216,6 @@ 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>,
@@ -275,7 +242,7 @@ export class HaDevicePicker extends LitElement {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
@@ -312,76 +279,46 @@ export class HaDevicePicker extends LitElement {
}
);
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}
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
slot="start"
></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
}
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 =
@@ -438,8 +375,6 @@ 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}
-3
View File
@@ -34,8 +34,6 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property({ type: Boolean }) public disabled = false;
@property() public variant:
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -59,7 +57,6 @@ export class HaButtonToggleGroup extends LitElement {
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.disabled=${this.disabled}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${
+3 -11
View File
@@ -143,8 +143,7 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
(option) => this._renderOption(option)
)
: nothing
}
@@ -152,14 +151,7 @@ export class HaControlSelect extends LitElement {
`;
}
/* 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) {
private _renderOption(option: ControlSelectOption) {
const isSelected = this.value === option.value;
return html`
@@ -170,7 +162,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${tabbable ? "0" : "-1"}
tabindex=${isSelected ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
+2 -3
View File
@@ -68,7 +68,6 @@ export class HaDurationInput extends LitElement {
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
@@ -236,8 +235,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this.data) {
const value = { ...this.data };
const value = this.data;
if (value) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
@@ -1,25 +0,0 @@
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;
};
@@ -81,7 +81,6 @@ export class HaChooseSelector extends LitElement {
.required=${this.required}
@value-changed=${this._handleValueChanged}
.helper=${this.helper}
.localizeValue=${this.localizeValue}
></ha-selector>`;
}
@@ -108,12 +107,7 @@ export class HaChooseSelector extends LitElement {
: {
[this._activeChoice!]: this.value,
};
const choice = ev.detail?.value || ev.target.value;
this._activeChoice = choice;
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
value[choice] =
this.selector.choose.choices[choice].selector.constant?.value;
}
this._activeChoice = ev.detail?.value || ev.target.value;
fireEvent(this, "value-changed", {
value: {
...value,
+14 -3
View File
@@ -35,7 +35,6 @@ 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";
@@ -800,8 +799,20 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
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) {
-1
View File
@@ -664,7 +664,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
+19 -70
View File
@@ -28,7 +28,6 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -159,7 +158,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -684,10 +682,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "area") {
// 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) => {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
@@ -719,18 +716,9 @@ 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 &&
deviceIds.has(entity.device_id) &&
entity.device_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
@@ -1013,28 +1001,6 @@ 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,
@@ -1042,41 +1008,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDeviceIds,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
replacingDeviceId,
`device${SEPARATOR}`
);
// 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.
).sort(this._sortBySortingLabel);
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,
false
deviceComboBoxKeys
);
}
// 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"));
@@ -1294,9 +1245,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
if (type === "area" || type === "floor") {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1316,27 +1265,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
(item as FloorComboBoxItem).type === "area" && hasFloor
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
(item as FloorComboBoxItem).type === "area" && hasFloor
? 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 { last?: boolean }).last}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
slot="start"
></ha-tree-indicator>
`
+3 -24
View File
@@ -8,31 +8,10 @@ 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"
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 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>
`;
}
-62
View File
@@ -1,62 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { HaListItemBase } from "./ha-list-item-base";
/**
* @element ha-list-item-value
* @extends {HaListItemBase}
*
* @summary
* Non-interactive label/value row for grouped lists: label on the start
* side, value content end-aligned. The value is the default slot so callers
* can render rich content (links, secondary lines).
*
* @slot - The value content.
*
* @csspart label - The label column.
* @csspart value - The value column.
*
* @cssprop --ha-list-item-value-max-width - Maximum width of the value column. Defaults to 60%.
*
* @attr {string} label - The row label.
*/
@customElement("ha-list-item-value")
export class HaListItemValue extends HaListItemBase {
@property({ type: String }) public label?: string;
protected override _renderInner(): TemplateResult {
return html`
<div part="label" class="label">${this.label}</div>
<div part="value" class="value"><slot></slot></div>
`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
}
.label {
flex: 1;
color: var(--secondary-text-color);
}
.value {
max-width: var(--ha-list-item-value-max-width, 60%);
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-value": HaListItemValue;
}
}
-88
View File
@@ -1,88 +0,0 @@
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,9 +393,6 @@ 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 ||
@@ -419,9 +416,6 @@ 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 || "")
@@ -430,7 +424,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
)
: [];
@@ -439,7 +433,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)
)
: [];
@@ -534,12 +528,6 @@ 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(
@@ -572,9 +560,6 @@ 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(
@@ -600,11 +585,6 @@ 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;
}
+11 -51
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } 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,13 +25,6 @@ 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", "");
@@ -43,12 +36,8 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -67,21 +56,15 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${classMap(containerClasses)}"
class="container ${containerOrientationClass}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<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 class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
<slot name="features"></slot>
</div>
`;
}
@@ -114,13 +97,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
.container.horizontal {
flex-direction: row;
}
@@ -176,30 +153,13 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
.container.horizontal ::slotted([slot="features"]) {
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;
-1
View File
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+76 -177
View File
@@ -117,22 +117,6 @@ 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
@@ -146,26 +130,18 @@ 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[],
options?: DescribeOptions
ignoreAlias = false
): string => {
try {
const description = tryDescribeTrigger(
trigger,
hass,
entityRegistry,
options
ignoreAlias
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -187,7 +163,7 @@ const tryDescribeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
options?: DescribeOptions
ignoreAlias = false
) => {
if (isTriggerList(trigger)) {
const triggers = ensureArray(trigger.triggers);
@@ -203,15 +179,14 @@ const tryDescribeTrigger = (
});
}
if (trigger.alias && !options?.ignoreAlias) {
if (trigger.alias && !ignoreAlias) {
return trigger.alias;
}
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -235,8 +210,7 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -267,16 +241,28 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
if (trigger.trigger === "numeric_state" && trigger.entity_id) {
const entities: string[] = [];
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(
@@ -292,39 +278,6 @@ 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`,
@@ -366,14 +319,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,
@@ -384,6 +337,17 @@ 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) {
@@ -463,32 +427,6 @@ 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`,
{
@@ -978,14 +916,14 @@ export const describeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
options?: DescribeOptions
ignoreAlias = false
): string => {
try {
const description = tryDescribeCondition(
condition,
hass,
entityRegistry,
options
ignoreAlias
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -1007,7 +945,7 @@ const tryDescribeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
options?: DescribeOptions
ignoreAlias = false
) => {
if (typeof condition === "string" && hasTemplate(condition)) {
return hass.localize(
@@ -1015,7 +953,7 @@ const tryDescribeCondition = (
);
}
if (condition.alias && !options?.ignoreAlias) {
if (condition.alias && !ignoreAlias) {
return condition.alias;
}
@@ -1037,8 +975,7 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -1064,8 +1001,7 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1122,20 +1058,17 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id && !hideEntities) {
if (!condition.entity_id) {
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,
@@ -1146,7 +1079,27 @@ 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(
@@ -1163,7 +1116,7 @@ const describeLegacyCondition = (
: state
);
}
} else if (condition.state != null && condition.state !== "") {
} else if (condition.state !== "") {
states.push(
stateObj
? condition.attribute
@@ -1184,37 +1137,6 @@ 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`,
{
@@ -1237,14 +1159,15 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
if (condition.condition === "numeric_state" && condition.entity_id) {
const entity_ids = 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
@@ -1257,30 +1180,6 @@ 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`,
+22 -157
View File
@@ -3,7 +3,6 @@ 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";
@@ -25,17 +24,12 @@ 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 {
@@ -47,10 +41,6 @@ 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 = (
@@ -63,8 +53,7 @@ export const computeDeviceAreaLabel = (
translationMetadata: HomeAssistant["translationMetadata"],
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
): DeviceAreaLabel => {
// Pass devices so a child device inherits its parent's area.
const area = getDeviceArea(device, areas, devices);
const area = getDeviceArea(device, areas);
const viaDevice = device.via_device_id
? devices[device.via_device_id]
@@ -72,23 +61,11 @@ export const computeDeviceAreaLabel = (
const viaDeviceName = viaDevice
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
: undefined;
const viaDeviceArea = viaDevice
? getDeviceArea(viaDevice, areas, devices)
: undefined;
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : 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
@@ -97,7 +74,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
return { areaName, viaDeviceName, viaDeviceAreaName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -125,14 +102,6 @@ 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 = (
@@ -149,7 +118,6 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -157,60 +125,26 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
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 = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) =>
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -218,11 +152,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return devEntities.every(
return entities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -236,11 +170,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -255,8 +189,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -285,7 +219,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
const { areaName, viaDeviceName, viaDeviceAreaName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -322,79 +256,10 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
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;
return outputDevices;
};
+1 -113
View File
@@ -15,13 +15,6 @@ 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[];
@@ -40,116 +33,11 @@ export interface DeviceRegistryEntry extends RegistryEntry {
area_id: string | null;
name_by_user: string | null;
entry_type: "service" | null;
disabled_by: DeviceDisabler | null;
disabled_by: "user" | "integration" | "config_entry" | null;
configuration_url: string | null;
primary_config_entry: string | null;
// Set when this device is a child (logical part) of another device.
// null for regular top-level devices.
parent_device_id: string | null;
}
/**
* A child device as it arrives over the wire from
* `config/device_registry/list`. A child is a lightweight logical part of a
* parent device (e.g. an outlet of a power strip); it only carries its own
* fields and inherits the rest from its parent. It is never stored in
* `hass.devices` in this shape {@link resolveChildDevices} turns every child
* into a complete {@link DeviceRegistryEntry} at ingestion, so downstream code
* only ever sees full device entries.
*/
export interface ChildDeviceRegistryEntry extends RegistryEntry {
id: string;
config_entry_id: string;
config_subentry_id: string | null;
identifiers: [string, string][];
name: string | null;
name_by_user: string | null;
labels: string[];
area_id: string | null;
disabled_by: DeviceDisabler | null;
parent_device_id: string;
}
/**
* The raw, mixed list returned by `config/device_registry/list`: full devices
* and stripped children, discriminated by the presence of full-device fields.
*/
export type DeviceRegistryListEntry =
DeviceRegistryEntry | ChildDeviceRegistryEntry;
/** Whether a resolved device entry is a child (logical part) of another device. */
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
-55
View File
@@ -22,7 +22,6 @@ 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";
@@ -37,7 +36,6 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -313,59 +311,6 @@ 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;
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
export const getLogbookDataFromServer = (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+2 -5
View File
@@ -2,15 +2,12 @@ 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 {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
import type { LovelaceViewRawConfig } from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: LovelaceDashboardBackgroundConfig;
background?: string;
views: LovelaceViewRawConfig[];
}
+1 -4
View File
@@ -31,9 +31,6 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -63,7 +60,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: LovelaceDashboardBackgroundConfig;
background?: string | LovelaceViewBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+19 -1
View File
@@ -1,3 +1,4 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
+10 -23
View File
@@ -14,7 +14,6 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -727,10 +726,9 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
// 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) => {
Object.values(devices).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -792,8 +790,9 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -801,7 +800,11 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
);
) {
return true;
}
return false;
});
if (hasMatchingdevice) {
return true;
}
@@ -843,8 +846,6 @@ 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
);
@@ -1111,11 +1112,6 @@ 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,
@@ -1147,15 +1143,6 @@ 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,
+4 -8
View File
@@ -7,10 +7,7 @@ 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 {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import 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";
@@ -128,7 +125,9 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
if (
areaDevices.some((device) =>
@@ -179,9 +178,6 @@ 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
View File
@@ -22,6 +22,7 @@ export enum VacuumEntityFeature {
STOP = 8,
RETURN_HOME = 16,
FAN_SPEED = 32,
BATTERY = 64,
STATUS = 128,
SEND_COMMAND = 256,
LOCATE = 512,
@@ -33,6 +34,7 @@ export enum VacuumEntityFeature {
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
battery_level?: number;
fan_speed?: string;
fan_speed_list?: string[];
[key: string]: any;
+2 -3
View File
@@ -566,10 +566,9 @@ export const getWeatherStateIcon = (
if (userDefinedIcon) {
return html`
<div
style=${styleMap({
"background-size": "cover",
style="background-size: cover;${styleMap({
"background-image": userDefinedIcon,
})}
})}"
></div>
`;
}
+4 -79
View File
@@ -2,87 +2,12 @@ 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 {
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,
};
});
};
import type { DeviceRegistryEntry } from "./device/device_registry";
export const fetchDeviceRegistry = (conn: Connection) =>
conn
.sendMessagePromise<DeviceRegistryListEntry[]>({
type: "config/device_registry/list",
})
.then(resolveChildDevices);
conn.sendMessagePromise<DeviceRegistryEntry[]>({
type: "config/device_registry/list",
});
const subscribeDeviceRegistryUpdates = (
conn: Connection,
+15 -5
View File
@@ -7,11 +7,21 @@ import { isNumericEntity } from "../../data/history";
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export {
isMoreInfoView,
MORE_INFO_VIEWS,
type MoreInfoView,
} from "./more-info-view";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -159,6 +159,23 @@ class MoreInfoVacuum extends LitElement {
`;
}
// Use deprecated battery_level and battery_icon attributes
if (
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
this.stateObj.attributes.battery_level
) {
return html`
<span class="battery" slot="after-time">
<span
>${Math.round(
this.stateObj.attributes.battery_level
)}${blankBeforePercent(this._i18n.locale)}%</span
>
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
</span>
`;
}
return nothing;
}
@@ -11,7 +11,6 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { transform } from "../../../common/decorators/transform";
import { formatNumber } from "../../../common/number/format_number";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-relative-time";
import "../../../components/ha-spinner";
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -133,33 +127,15 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
this._forecastType = getDefaultForecastType(this.stateObj);
this._subscribeForecastEvents();
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
@@ -533,17 +509,8 @@ class MoreInfoWeather extends LitElement {
`;
}
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
private _handleForecastTypeChanged(ev: CustomEvent): void {
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+93 -173
View File
@@ -1,24 +1,16 @@
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/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import "../../components/ha-card";
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";
@@ -34,7 +26,6 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
href?: string;
}
@customElement("ha-more-info-details")
@@ -49,15 +40,8 @@ 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];
@@ -70,93 +54,9 @@ class HaMoreInfoDetails extends LitElement {
return nothing;
}
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 { stateEntries, attributes, yamlData } = this._getDetailData(
this._stateObj
);
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">
@@ -169,41 +69,43 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
<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>`
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
</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>
<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>
`
}
</div>
@@ -275,20 +177,6 @@ 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">
@@ -304,25 +192,28 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<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>
<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>
`
);
}
@@ -356,18 +247,47 @@ class HaMoreInfoDetails extends LitElement {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-grouped-list + ha-grouped-list {
.section + .section {
margin-top: var(--ha-space-4);
}
a {
color: var(--primary-color);
.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);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-3) var(--ha-space-4);
padding: var(--ha-space-2) 0;
}
`;
}
+2 -62
View File
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -44,10 +43,8 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import {
getHistoryState,
navigate,
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -88,7 +85,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -106,9 +102,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
}
@@ -154,12 +147,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@@ -194,8 +181,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
this._childViewStack = [];
@@ -231,9 +216,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -242,8 +224,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -291,10 +271,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -302,38 +279,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
},
});
this._currView = view;
this._syncUrl();
}
private _syncUrl() {
if (!this._returnUrl || !this._entityId) {
return;
}
replaceCurrentUrl(
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
@@ -361,9 +306,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
}
@@ -1064,22 +1007,19 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childViewStack = [];
this._loadEntityRegistryEntry();
this._syncUrl();
}
private _enableEscapeKeyClose = () => {
-15
View File
@@ -1,15 +0,0 @@
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
+3 -4
View File
@@ -60,7 +60,6 @@ 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 {
@@ -800,9 +799,9 @@ export class QuickBar extends LitElement {
title: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_title`
),
text: html`${this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_description`
)}<br /><br /><automation-restart-status></automation-restart-status>`,
text: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_description`
),
confirmText: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_action`
),
@@ -1,54 +0,0 @@
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;
}
}
+6 -7
View File
@@ -41,7 +41,6 @@ import {
showConfirmationDialog,
} from "../generic/show-dialog-box";
import { showRestartWaitDialog } from "./show-dialog-restart";
import "./automation-restart-status";
@customElement("dialog-restart")
class DialogRestart extends LitElement {
@@ -358,12 +357,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
} <br /><br /><automation-restart-status></automation-restart-status>`,
`ui.dialogs.restart.${action}.confirm_description`
)}${
backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing
}`,
confirmText: this.hass.localize(
`ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
),
+5
View File
@@ -74,6 +74,11 @@ export class MockVacuumEntity extends MockBaseEntity {
stateAttrs.fan_speed = attrs.fan_speed ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
stateAttrs.battery_level = attrs.battery_level ?? null;
stateAttrs.battery_icon = attrs.battery_icon ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
stateAttrs.status = attrs.status ?? null;
}
-40
View File
@@ -6,16 +6,12 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -148,7 +144,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
this._restoreMoreInfoFromUrl();
}
}
@@ -183,12 +178,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
this._restoreMoreInfoFromUrl()
);
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
// Handle clicking on links
window.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
@@ -308,35 +297,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
});
}
private _restoreMoreInfoFromUrl() {
// Only restore once the main UI is rendered so the dialog has access to
// the loaded entities.
if (this.render !== this.renderHass) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
if (!entityId) {
return;
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
+11 -69
View File
@@ -6,13 +6,9 @@ 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 {
getAnalyticsDetails,
setAnalyticsPreferences,
} from "../data/analytics";
import { setAnalyticsPreferences } from "../data/analytics";
import { onboardAnalyticsStep } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
@@ -26,11 +22,9 @@ class OnboardingAnalytics extends LitElement {
@state() private _error?: string;
// 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;
@state() private _analyticsDetails: Analytics = {
preferences: {},
};
protected render(): TemplateResult {
return html`
@@ -46,26 +40,13 @@ class OnboardingAnalytics extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
${
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>
`
}
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
${this._error ? html`<div class="error">${this._error}</div>` : ""}
<div class="footer">
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
@@ -82,35 +63,6 @@ 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(
@@ -124,9 +76,6 @@ class OnboardingAnalytics extends LitElement {
private async _save(ev) {
ev.preventDefault();
if (!this._analyticsDetails) {
return;
}
try {
await setAnalyticsPreferences(
this.hass,
@@ -149,13 +98,6 @@ 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,7 +370,6 @@ 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,6 +52,7 @@ import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -63,6 +64,7 @@ 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 {
@@ -74,8 +76,6 @@ 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,14 +182,12 @@ export default class HaAutomationConditionRow extends LitElement {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
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 conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
@@ -226,15 +224,13 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(
target,
targetRequired,
descriptionHasTarget && !this._isNew,
conditionTargetSpec,
this.condition.condition !== "device"
)
@@ -604,30 +600,6 @@ 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,
@@ -805,9 +777,7 @@ export default class HaAutomationConditionRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, {
ignoreAlias: true,
})
describeCondition(this.condition, this.hass, this._entityReg, true)
),
defaultValue: this.condition.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -7,7 +7,6 @@ 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";
@@ -430,8 +429,20 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
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) {
@@ -2,12 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import {
mdiCloseThick,
mdiCog,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiExclamationThick,
mdiHelpCircleOutline,
mdiInformationOutline,
mdiMenuDown,
@@ -131,28 +129,6 @@ import {
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
const renderIconBadge = (path: string, color: string) => html`
<div
style=${styleMap({
position: "absolute",
top: "-5px",
insetInlineEnd: "-7px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: color,
boxShadow: "0 0 0 2px var(--data-table-background-color)",
color: "var(--data-table-background-color)",
"--mdc-icon-size": "12px",
})}
>
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
</div>
`;
type AutomationItem = AutomationEntity & {
name: string;
area: string | undefined;
@@ -327,11 +303,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
localize: LocalizeFunc,
entitiesToCheck?: any[]
): DataTableColumnContainer<AutomationItem> => {
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
localize,
this.hass
);
const columns: DataTableColumnContainer<AutomationItem> = {
icon: {
title: "",
@@ -339,34 +310,18 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
type: "icon",
moveable: false,
showNarrow: true,
template: (automation) => {
const unavailable = automation.state === UNAVAILABLE;
const disabled = automation.state === "off";
return html`<div
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
>
<ha-state-icon
.stateObj=${automation}
.stateValue=${unavailable || disabled ? "on" : undefined}
style=${styleMap({
display: "flex",
margin: "0",
color: unavailable
template: (automation) =>
html`<ha-state-icon
.stateObj=${automation}
style=${styleMap({
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: disabled
? "var(--disabled-color)"
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>
${
unavailable
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
: disabled
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
: nothing
}
</div>`;
},
})}
></ha-state-icon>`,
},
entity_id: getEntityIdHiddenTableColumn(),
name: {
@@ -387,33 +342,23 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
area: getAreaTableColumn(localize),
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: {
...triggeredAtColumn,
template: (automation) =>
narrow && automation.state === "off"
? nothing
: triggeredAtColumn.template!(automation),
},
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
formatted_state: {
minWidth: "82px",
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.state === "off"
? localize("ui.panel.config.automation.picker.disabled")
: nothing
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -1,6 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
export const getDeviceTarget = (
deviceId?: string
): HassServiceTarget | undefined =>
deviceId ? { device_id: [deviceId] } : undefined;
@@ -1,9 +0,0 @@
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,6 +60,7 @@ 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";
@@ -73,8 +74,6 @@ 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";
@@ -215,12 +214,11 @@ export default class HaAutomationTriggerRow extends LitElement {
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger];
const hasEntityTarget = type === "state" || type === "numeric_state";
const target = this._getTarget(type, descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
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 triggerTargetSpec =
type === "platform"
@@ -250,16 +248,12 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`
}
<h3 slot="header">
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${
target !== undefined || targetRequired
target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(
target,
targetRequired,
descriptionHasTarget && !this._isNew,
triggerTargetSpec,
type !== "device"
)
@@ -601,27 +595,6 @@ 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,
@@ -884,9 +857,7 @@ export default class HaAutomationTriggerRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
ignoreAlias: true,
})
describeTrigger(this.trigger, this.hass, this._entityReg, true)
),
defaultValue: this.trigger.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -4,7 +4,6 @@ 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";
@@ -423,8 +422,20 @@ export class HaPlatformTrigger extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
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) {
@@ -53,11 +53,12 @@ interface UpdateGroup {
key: string;
title: string;
entities: UpdateEntity[];
showUpdateButton: boolean;
showUpdateAll: boolean;
}
const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
const INTEGRATIONS_KEY = "__integrations__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -214,7 +215,7 @@ class HaConfigSectionUpdates extends LitElement {
${group.title}
</div>
${
group.showUpdateButton
group.showUpdateAll
? html`
<ha-button
appearance="plain"
@@ -223,12 +224,10 @@ class HaConfigSectionUpdates extends LitElement {
.disabled=${group.entities.every((entity) =>
updateIsInstalling(entity)
)}
@click=${this._updateGroup}
@click=${this._updateAll}
>
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
"ui.panel.config.updates.update_all"
)}
</ha-button>
`
@@ -348,7 +347,7 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}
private async _updateGroup(ev: Event) {
private async _updateAll(ev: Event) {
const group = (ev.currentTarget as any).group as UpdateGroup;
const entityIds = group.entities
.filter((entity) => !updateIsInstalling(entity))
@@ -414,6 +413,7 @@ 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,29 +422,36 @@ class HaConfigSectionUpdates extends LitElement {
}
const domain =
entitySources?.[entity.entity_id]?.domain ??
entityRegistry[entity.entity_id]?.platform ??
"unknown";
entityRegistry[entity.entity_id]?.platform;
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 integrationGroups: UpdateGroup[] = [];
const multiInstanceGroups: UpdateGroup[] = [];
byDomain.forEach((entries, domain) => {
integrationGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateButton: true,
});
if (entries.length >= 2) {
multiInstanceGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateAll: true,
});
} else {
otherIntegrationEntities.push(...entries);
}
});
integrationGroups.sort((a, b) =>
multiInstanceGroups.sort((a, b) =>
caseInsensitiveStringCompare(a.title, b.title, language)
);
@@ -455,18 +462,27 @@ class HaConfigSectionUpdates extends LitElement {
key: SYSTEM_KEY,
title: localize("ui.panel.config.updates.group_system"),
entities: systemEntities,
showUpdateButton: false,
showUpdateAll: false,
});
}
groups.push(...integrationGroups);
groups.push(...multiInstanceGroups);
if (otherIntegrationEntities.length) {
groups.push({
key: INTEGRATIONS_KEY,
title: localize("ui.panel.config.updates.group_integrations"),
entities: otherIntegrationEntities,
showUpdateAll: true,
});
}
if (appEntities.length) {
groups.push({
key: APPS_KEY,
title: localize("ui.panel.config.updates.group_apps"),
entities: appEntities,
showUpdateButton: true,
showUpdateAll: true,
});
}
@@ -88,7 +88,7 @@ class HaConfigUpdates extends LitElement {
const areaName =
deviceEntry && deviceEntry.entry_type !== "service"
? getDeviceArea(deviceEntry, this._areas, this._devices)?.name ||
? getDeviceArea(deviceEntry, this._areas)?.name ||
this._localize("ui.panel.config.updates.no_area")
: undefined;
@@ -1,168 +0,0 @@
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,27 +93,6 @@ 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,11 +75,7 @@ export class HaDeviceViaDevicesCard extends LitElement {
? viaDevices
: viaDevices.slice(0, MAX_VISIBLE_VIA_DEVICES)
).map((viaDevice) => {
const area = getDeviceArea(
viaDevice,
this.hass.areas,
this.hass.devices
);
const area = getDeviceArea(viaDevice, this.hass.areas);
const entityCount = entityCounts[viaDevice.id] ?? 0;
const secondary = [
area?.name,
@@ -125,10 +125,7 @@ class DialogDeviceRegistryDetail extends DirtyStateProviderMixin<DeviceFormState
<div class="row">
<ha-switch
.checked=${!this._disabledBy}
.disabled=${
this._params.device.disabled_by === "config_entry" ||
this._params.device.disabled_by === "device"
}
.disabled=${this._params.device.disabled_by === "config_entry"}
@change=${this._disabledByChanged}
>
</ha-switch>
@@ -33,7 +33,6 @@ 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";
@@ -107,7 +106,6 @@ 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";
@@ -444,7 +442,7 @@ export class HaConfigDevicePage extends LitElement {
const batteryChargingState = batteryChargingEntity
? this.hass.states[batteryChargingEntity.entity_id]
: undefined;
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const area = device.area_id ? this.hass.areas[device.area_id] : undefined;
const deviceInfo: TemplateResult[] = integrations.length
? [
@@ -901,10 +899,6 @@ 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,15 +459,6 @@ 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
@@ -481,15 +472,6 @@ 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,
@@ -504,13 +486,9 @@ export class HaConfigDeviceDashboard extends LitElement {
);
const floorArea =
getDeviceArea(device, areas, this.hass.devices) ??
getDeviceArea(device, areas) ??
(device.via_device_id && this.hass.devices[device.via_device_id]
? getDeviceArea(
this.hass.devices[device.via_device_id],
areas,
this.hass.devices
)
? getDeviceArea(this.hass.devices[device.via_device_id], areas)
: undefined);
const floorId = floorArea?.floor_id;
const floorName =
@@ -545,29 +523,6 @@ 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),
@@ -628,16 +583,6 @@ 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`
@@ -658,19 +603,6 @@ 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,8 +11,6 @@ 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";
@@ -25,11 +23,9 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -108,7 +104,18 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${this._renderName(device)}
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -148,32 +155,6 @@ 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
@@ -299,22 +280,6 @@ 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,8 +11,6 @@ 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";
@@ -25,11 +23,9 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -108,7 +104,18 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${this._renderName(device)}
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -148,32 +155,6 @@ 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
@@ -295,22 +276,6 @@ 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,11 +11,9 @@ 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 {
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -176,7 +174,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? computeEnergyLabel(
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,11 +11,9 @@ 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 {
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -172,7 +170,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? computeEnergyLabel(
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,6 +7,7 @@ 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";
@@ -14,7 +15,6 @@ 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,18 +73,20 @@ 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,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
primary: name || entityName || deviceName || statisticId,
secondary,
stateObj,
search_labels: {
entityName: entityName || null,
@@ -10,15 +10,12 @@ 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,
@@ -51,24 +48,12 @@ 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, this.hass.devices);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const area = getDeviceArea(device, this.hass.areas);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
@@ -80,25 +65,6 @@ 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"
@@ -385,22 +351,12 @@ 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,7 +45,6 @@ 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 {
@@ -480,16 +479,14 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list-item>
${
this._devicesExpanded
? groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
? ownDevices.map(
(device) =>
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
@@ -512,16 +509,14 @@ export class HaConfigEntryRow extends LitElement {
`
)}`
: html`
${groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
${ownDevices.map(
(device) =>
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,7 +15,6 @@ 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";
@@ -192,16 +191,14 @@ class HaConfigSubEntryRow extends LitElement {
${
this._expanded
? html`
${groupDevicesByParent(devices).map(
({ device, isChild, isLastChild }) =>
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
${services.map(
@@ -240,7 +240,7 @@ export class BluetoothNetworkVisualization extends LitElement {
const scannerDevice = this._sourceDevices[scanner.source] as
DeviceRegistryEntry | undefined;
const area = scannerDevice
? getDeviceArea(scannerDevice, this.hass.areas, this.hass.devices)
? getDeviceArea(scannerDevice, this.hass.areas)
: 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, this.hass.devices)
? getDeviceArea(device, this.hass.areas)
: undefined;
nodes.push({
id: node.address,
@@ -350,9 +350,7 @@ 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, this.hass.devices)
: undefined;
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const areaLine = area
? html`<br /><b
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
@@ -64,9 +64,7 @@ export function createZHANetworkChartData(
const haDevice = hass.devices[device.device_reg_id] as
DeviceRegistryEntry | undefined;
const area = haDevice
? getDeviceArea(haDevice, hass.areas, hass.devices)
: undefined;
const area = haDevice ? getDeviceArea(haDevice, hass.areas) : undefined;
// Create node
nodes.push({
id: device.ieee,
@@ -162,7 +162,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
const haDevice = this.hass.devices[device.device_reg_id] as
DeviceRegistryEntry | undefined;
const area = haDevice
? getDeviceArea(haDevice, this.hass.areas, this.hass.devices)
? getDeviceArea(haDevice, this.hass.areas)
: undefined;
return html`<b>IEEE: </b>${device.ieee}<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
@@ -177,11 +177,7 @@ class DialogZWaveJSRebuildNetworkRoutesDetail extends DialogMixin<ZWaveJSRebuild
) ||
this._i18n.localize("ui.components.device-picker.unnamed_device");
const area = getDeviceArea(
device,
this._registries.areas,
this._registries.devices
);
const area = getDeviceArea(device, this._registries.areas);
const areaName = area ? computeAreaName(area) : undefined;
@@ -183,9 +183,7 @@ 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, this.hass.devices)
: undefined;
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
return html`<ha-chart-tooltip-marker
.color=${String((params as CallbackDataParams).color ?? "")}
></ha-chart-tooltip-marker>
@@ -297,7 +295,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, this.hass.devices)
? getDeviceArea(device, this.hass.areas)
: undefined;
nodes.push({
id: String(node.node_id),
@@ -1,46 +1,29 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } 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 { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } 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<DashboardDetailState>()(
LitElement
) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -55,59 +38,39 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
@state() private _submitting = false;
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
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" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
this._initDirtyTracking({ type: "deep" }, this._data);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
icon: suggestions?.icon,
title: suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
// 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._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
this._updateDirtyState(this._data!);
}
}
public closeDialog() {
public closeDialog(): void {
this._open = false;
}
private _dialogClosed() {
private _dialogClosed(): void {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -119,18 +82,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
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
@@ -145,43 +96,41 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
return html`
<ha-dialog
.open=${this._open}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
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"
)
}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${
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>
`
)}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
${
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
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</div>
<ha-dialog-footer slot="footer">
${
@@ -236,46 +185,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
`;
}
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) =>
[
@@ -342,16 +251,15 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
)
: "";
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
if (ev.detail.value.url_path !== this._data?.url_path) {
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!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)
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -360,30 +268,13 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
};
}
}
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
} else {
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._data = value;
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -395,55 +286,36 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
: baseSlug,
};
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
this._updateDirtyState(this._data!);
}
private async _updateDashboard() {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
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);
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
);
}
this._markDirtyStateClean();
this.closeDialog();
@@ -467,25 +339,10 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
}
}
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 {
@@ -494,30 +351,7 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
}
static get styles(): CSSResultGroup {
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;
}
`,
];
return [haStyleDialog, css``];
}
}
@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -18,8 +17,6 @@ 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>

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