Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 2f4e5279a5 Localize built-in YAML field descriptions
The built-in trigger/condition/action schemas carried English descriptions
inline, which surfaced untranslated in the hover tooltip and completion info.

Store translation keys in `description` instead — the hover element and the
completion source already localize it and fall back to the raw string, which
keeps backend-supplied descriptions (services, integration triggers and
conditions) working unchanged. Adds the strings under
`ui.panel.config.automation.editor.yaml_schema`, plus a test asserting every
built-in description key resolves in en.json.

Also drops the remaining `any` casts in serviceActionSchema in favour of a
declared ServiceField shape.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:56:12 +02:00
Bram KragtenandClaude Opus 5 848496b09e Review fixes: live schema context, memoization, localized editor chrome
- ha-code-editor: adapt the YAML schema wiring to the context-based API
  (states/registries/i18n) instead of the removed `hass` property.
- haYamlCompletionSource now takes a context getter instead of a captured
  snapshot, so a schema or registry that changes after the editor was created
  is picked up. Previously the completion source kept the schema and registry
  it was built with for the editor's lifetime.
- Memoize the trigger/condition/action YAML schema on the type discriminator
  rather than the whole config object, which changes identity on every
  keystroke and made both lint compartments reconfigure per character.
- Memoize the registry-derived completion lists; they were rebuilt for every
  completion request.
- Localize the editor chrome: "Required" badge, example/default labels, lint
  diagnostics, and Jinja value hints.
- buildTargetSchema: forward a target's entity/device filters as selector
  `filter`, instead of nesting the whole target definition under `entity`.
- Replace the `__allowUnknownFields` string marker with a symbol, drop `any`
  casts in the hover source and selector helpers, fix stale comments.
- Add unit tests for the schema converters and the lint source.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:42:42 +02:00
Bram Kragten 2c2eef7942 add floor_id and label_id to YAML hover tooltip selector type mapping 2026-08-13 15:27:15 +02:00
Bram Kragten 4e3cc4c705 Add to developer tools action 2026-08-13 15:27:15 +02:00
Bram Kragten 8f96d2c6e4 add built-in triggers and conditions, add floor and label 2026-08-13 15:26:55 +02:00
Bram Kragten 2b9347fd72 Add support for numeric threshold and behavior selectors 2026-08-13 15:25:27 +02:00
Bram Kragten 12f54835f0 Add field-aware YAML completions, hover tooltips, and linting to automation editor
- New files: yaml_field_schema.ts (types), yaml_ha_completions.ts (completion/hover/lint
  sources), ha_completion_items.ts (entity/device/area completion builders),
  ha-code-editor-yaml-hover.ts (key hover tooltip element),
  yaml_schema_helpers.ts (action/trigger/condition schema converters)
- ha-code-editor: wire schema completions, hover tooltips, and schema linter;
  replace setDiagnostics-based syntax error reporting with a linter() so syntax
  and schema diagnostics no longer overwrite each other; add forceLinting export
- ha-yaml-editor: suppress generic entity autocomplete when schema is present
- Action/trigger/condition editors: pass yamlFieldSchema to ha-yaml-editor;
  memoize on (services, localize) not full hass to avoid constant re-renders
- codemirror.ts: export forceLinting; mount-aware BlockMapping traversal in lint
  source to handle jinja({ base: yaml() }) tree structure (Template → Text with
  NodeProp.mounted subtree)
2026-08-13 15:25:11 +02:00
22ca986cce Skip source maps in the gallery build for e2e (#53625)
The gallery build is the only e2e build that still emits source maps: unlike
demo and the e2e test app, `bundle.config.gallery` never accepted
`isTestBuild`, so the production build always used `nosources-source-map` —
even though the artifact is only ever loaded by Playwright.

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

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

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

* Don't need a new dialog

* Update src/translations/en.json

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

* Show running automations in quick bar restart confirmation

---------

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

* Include entity context in YAML details

* Remove category from entity details

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

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

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

* Show update button on cards with a single update

---------

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

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

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

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

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

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

* Resolve child device effective area in the UI

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

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

* Address review on child devices data layer

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

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

* Inherit the parent device area when resolving entity areas

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

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

---------

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

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

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

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

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

* Keep the Actions write scope away from pull request code

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

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

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

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

---------

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

* Add tests for entity-less trigger and condition descriptions
2026-08-13 06:53:44 +02:00
4b7d3a7e4f Add rspack persistent cache (nightly writes, CI reads) (#53611)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-12 22:30:42 +02:00
Petar PetrovandGitHub 88be7adafa Stop number box rows from reserving unused space (#53614) 2026-08-12 14:42:09 +01:00
87 changed files with 5451 additions and 566 deletions
+4
View File
@@ -11,6 +11,9 @@ inputs:
is-test:
description: Set IS_TEST for the build (skips source maps and compression)
default: "false"
rspack-cache:
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
default: ""
runs:
using: composite
@@ -21,3 +24,4 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
RSPACK_CACHE: ${{ inputs.rspack-cache }}
+71 -4
View File
@@ -97,10 +97,12 @@ jobs:
run: yarn run test
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
# Runs alongside lint and test rather than after them: the build only needs
# the dependency tree, and with the rspack cache it is no longer expensive
# enough to be worth serialising behind the other checks. The
# cancel-on-failure job below stops the run as soon as a check fails, so a
# broken pull request does not finish building.
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -111,12 +113,26 @@ jobs:
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
# nightly.yaml). rspack itself decides what is still valid (version +
# buildDependencies + node_modules snapshot), so the GHA key just restores
# the latest nightly cache; no fingerprint, and no save step (CI never
# writes the shared cache).
- name: Restore rspack cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -132,3 +148,54 @@ jobs:
path: hass_frontend/
if-no-files-found: error
retention-days: 7
# Now that the checks run in parallel, a failing lint or test no longer stops
# the build from finishing on its own, so this watches them and cancels the
# whole run on the first failure.
#
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
# other jobs check out the pull request and run its build scripts — handing
# them that scope would give PR-controlled code (or a compromised dependency)
# write access to Actions. This job never checks out the repository, so the
# elevated token stays away from PR code. It also cannot be a job that
# `needs` the checks: that would only start once they have all finished, which
# is exactly too late to cancel anything.
cancel-on-failure:
name: Cancel run on failure
needs: prepare-dependencies
runs-on: ubuntu-latest
permissions:
actions: write
timeout-minutes: 30
steps:
- name: Cancel the run when a check fails
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
watched='^(Lint and check format|Run tests|Build frontend)$'
while :; do
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
2>/dev/null || true)
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
if [ -n "$failed" ]; then
echo "Cancelling the run, these checks failed:"
printf '%s\n' "$failed"
gh run cancel "$RUN_ID" --repo "$REPO" || true
exit 0
fi
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && $2 != "completed" { print $1 }')
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
echo "All checks finished without failure"
exit 0
fi
sleep 15
done
+1
View File
@@ -137,6 +137,7 @@ jobs:
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+24 -1
View File
@@ -58,9 +58,24 @@ jobs:
restore-keys: |
compress-cache-${{ runner.os }}-
# The nightly writes the rspack persistent cache; CI reads it read-only
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
# + node_modules snapshot), so the cache rolls forward daily and a single
# dependency bump keeps most of it warm instead of dropping the lineage.
- name: Restore rspack cache
id: rspack-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build nightly Python wheels
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
RSPACK_CACHE: readwrite
run: |
pip install build
yarn install
@@ -69,7 +84,7 @@ jobs:
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
# Not gated on the restore step: a transient restore failure (it is
# Not gated on the restore steps: a transient restore failure (they are
# continue-on-error) must not stop us persisting a freshly built cache.
- name: Save compression cache
if: success()
@@ -79,6 +94,14 @@ jobs:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Save rspack cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+1
View File
@@ -7,6 +7,7 @@ dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+2 -1
View File
@@ -277,7 +277,7 @@ module.exports.config = {
};
},
gallery({ isProdBuild, latestBuild }) {
gallery({ isProdBuild, latestBuild, isTestBuild }) {
return {
name: "gallery" + nameSuffix(latestBuild),
entry: {
@@ -287,6 +287,7 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isTestBuild,
defineOverlay: {
__DEMO__: true,
},
+1
View File
@@ -252,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
createGalleryConfig({
isProdBuild: true,
latestBuild: true,
isTestBuild: env.isTestBuild(),
})
)
);
+89 -3
View File
@@ -1,4 +1,6 @@
const { existsSync } = require("fs");
const fs = require("fs");
const { existsSync } = fs;
const path = require("path");
const rspack = require("@rspack/core");
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
const paths = require("./paths.cjs");
const bundle = require("./bundle.cjs");
// Build-toolchain packages whose version changes the emitted bytes but which
// are loader/compiler machinery, not modules in the build graph — so rspack's
// node_modules snapshot cannot see them. Their versions are folded into the
// persistent cache `version` so a toolchain upgrade invalidates the cache,
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
const TOOLCHAIN_PACKAGES = [
"@rspack/core",
"@babel/core",
"@babel/preset-env",
"babel-plugin-polyfill-corejs3",
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
"@babel/runtime",
"babel-loader",
"core-js",
"terser",
"terser-webpack-plugin",
"browserslist",
"caniuse-lite",
];
// Our own build logic — the config, loaders and babel plugins. Their contents
// (not their paths) go into the cache version, so a change invalidates the
// cache the same way `buildDependencies` would, but without tying validity to
// absolute paths — rspack compares buildDependencies by path, which breaks a
// cache reused on another machine/checkout (a different workspace path).
const CONFIG_FILES = [
__filename,
path.join(__dirname, "bundle.cjs"),
path.join(__dirname, "minify-template-literals-loader.cjs"),
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
];
// Content hash of the toolchain versions and our own build files, used as the
// persistent cache `version`. Everything here is path-independent so the cache
// stays valid when reused on a different machine or checkout path.
const cacheVersion = () => {
const parts = [
...TOOLCHAIN_PACKAGES.map(
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
),
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
];
return require("crypto")
.createHash("sha256")
.update(parts.join("\n"))
.digest("hex")
.slice(0, 16);
};
class LogStartCompilePlugin {
ignoredFirst = false;
@@ -376,6 +433,33 @@ const createRspackConfig = ({
])
),
},
// Persistent filesystem cache for production builds, opt-in per environment
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
// tests) = no cache.
...(isProdBuild && process.env.RSPACK_CACHE
? {
cache: {
type: "persistent",
// `name` is already unique per variant (frontend-modern/-legacy).
name,
// Content-based version (node major + toolchain versions + our own
// build files). Everything is path-independent, so the cache stays
// valid when reused on another machine/checkout. Runtime deps are
// deliberately absent — rspack's node_modules snapshot invalidates
// their modules per-package, so a single unrelated bump keeps the
// rest warm. buildDependencies is intentionally not used: rspack
// compares it by absolute path, which breaks cross-machine reuse.
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
storage: {
type: "filesystem",
directory: path.resolve(paths.root_dir, ".rspack-cache"),
},
// CI reads the nightly-written cache but must not modify it.
readonly: process.env.RSPACK_CACHE === "readonly",
},
}
: {}),
experiments: {
outputModule: true,
},
@@ -405,8 +489,10 @@ const createDemoConfig = ({
const createCastConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
createRspackConfig(
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
);
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
+1
View File
@@ -14,6 +14,7 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
+3
View File
@@ -87,6 +87,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -111,6 +112,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -135,6 +137,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -50,6 +50,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -74,6 +75,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -100,6 +100,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -124,6 +125,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -148,6 +150,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -238,6 +238,7 @@ const createDeviceRegistryEntries = (
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -2,10 +2,31 @@ import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { HomeAssistant } from "../../../types";
/**
* Return the effective area id of a device: a child device without an area of
* its own inherits its parent's area (mirrors core's
* async_get_effective_area_id). Nesting is single-level, so no recursion.
*/
export const getDeviceAreaId = (
device: DeviceRegistryEntry,
devices: HomeAssistant["devices"]
): string | undefined => {
if (device.area_id) {
return device.area_id;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id ?? undefined;
}
return undefined;
};
export const getDeviceArea = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
// Required so every caller resolves a child device's effective area
// consistently, see getDeviceAreaId.
devices: HomeAssistant["devices"]
): AreaRegistryEntry | undefined => {
const areaId = device.area_id;
const areaId = getDeviceAreaId(device, devices);
return areaId ? areas[areaId] : undefined;
};
@@ -8,6 +8,7 @@ import type {
} from "../../../data/entity/entity_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";
import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
@@ -46,7 +47,11 @@ export const getEntityAreaId = (
if (!entry) return undefined;
const deviceId = entry.device_id;
const device = deviceId ? devices[deviceId] : undefined;
return entry.area_id || device?.area_id || undefined;
return (
entry.area_id ||
(device ? getDeviceAreaId(device, devices) : undefined) ||
undefined
);
};
export const getEntityEntryContext = (
@@ -60,7 +65,8 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const areaId = entry?.area_id || device?.area_id;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
const floorId = area?.floor_id;
const floor = floorId ? floors[floorId] : undefined;
@@ -73,7 +73,7 @@ export class DialogDeviceReplaced
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const area = device ? getDeviceArea(device, areas, devices) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
+1 -1
View File
@@ -242,7 +242,7 @@ export class HaDevicePicker extends LitElement {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
+172
View File
@@ -0,0 +1,172 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
/**
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
* keys in the automation / script / card YAML editors.
*
* Shows:
* - Field name (monospace)
* - "required" badge when applicable
* - Description paragraph
* - Selector type hint
* - Example value
* - Default value
*/
@customElement("ha-code-editor-yaml-hover")
export class HaCodeEditorYamlHover extends LitElement {
@property({ attribute: false }) public fieldName = "";
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
/**
* Optional localize callback forwarded from the editor so translated
* descriptions can be rendered. When absent, strings are shown verbatim.
*/
@property({ attribute: false }) public localize?: LocalizeFunc;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
// Built-in field descriptions are translation keys; descriptions coming
// from the backend are already translated and fall through unchanged.
const description = schema.description
? this.localize?.(schema.description as LocalizeKeys) ||
schema.description
: undefined;
const selectorType = schema.selector
? Object.keys(schema.selector)[0]
: undefined;
return html`
<div class="header">
<code class="key">${this.fieldName}</code>
${
schema.required
? html`<span class="badge required"
>${this._label("required", "required")}</span
>`
: nothing
}
${
selectorType
? html`<span class="badge type">${selectorType}</span>`
: nothing
}
</div>
${description ? html`<div class="desc">${description}</div>` : nothing}
${
schema.example != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("example", "Example:")}</span
>
<code>${String(schema.example)}</code>
</div>`
: nothing
}
${
schema.default != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("default", "Default:")}</span
>
<code>${String(schema.default)}</code>
</div>`
: nothing
}
`;
}
private _label(
key: "required" | "example" | "default",
fallback: string
): string {
return (
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
);
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 320px;
line-height: 1.5;
font-size: 0.9em;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
code.key {
font-family: var(--ha-font-family-code);
font-size: 1em;
font-weight: bold;
}
.badge {
display: inline-block;
border-radius: 4px;
padding: 0 5px;
font-size: 0.78em;
line-height: 1.6;
font-family: var(--ha-font-family-body);
}
.badge.required {
background: color-mix(
in srgb,
var(--error-color, #db4437) 15%,
transparent
);
color: var(--error-color, #db4437);
}
.badge.type {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-text-color);
}
.desc {
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.meta {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--secondary-text-color);
}
.meta-label {
font-size: 0.85em;
opacity: 0.75;
flex-shrink: 0;
}
.meta code {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
}
}
+140 -94
View File
@@ -32,15 +32,21 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import {
buildEntityCompletions,
buildDeviceCompletions,
buildAreaCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "../resources/ha_completion_items";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import "./ha-code-editor-yaml-hover";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
@@ -89,6 +95,14 @@ export class HaCodeEditor extends ReactiveElement {
@property() public mode = "yaml";
/**
* Optional field schema for YAML mode. When set, the editor will provide
* field-aware key/value completions, hover tooltips, and linting for the
* known fields described by this map.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -168,6 +182,12 @@ export class HaCodeEditor extends ReactiveElement {
private _completionInfoDestroy?: () => void;
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
private _yamlSyntaxError: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null = null;
private _completionInfoRequest = 0;
private _completionInfoKey?: string;
@@ -202,6 +222,10 @@ export class HaCodeEditor extends ReactiveElement {
* Push a YAML parse error (or null to clear) into the lint gutter as a
* diagnostic. Avoids re-parsing the document the caller (ha-yaml-editor)
* already has the error from its own js-yaml load() call.
*
* Stores the error and triggers forceLinting() so the yamlLintCompartment
* linter re-runs and returns it as a diagnostic rather than calling
* setDiagnostics() which would wipe diagnostics from other linters.
*/
public setYamlError(
err: {
@@ -209,27 +233,10 @@ export class HaCodeEditor extends ReactiveElement {
reason?: string;
} | null
): void {
if (!this.codemirror || !this._loadedCodeMirror) return;
let diagnostics: {
from: number;
to: number;
severity: "error";
message: string;
}[] = [];
if (err) {
const doc = this.codemirror.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
this._yamlSyntaxError = err;
if (this.codemirror && this._loadedCodeMirror) {
this._loadedCodeMirror.forceLinting(this.codemirror);
}
this.codemirror.dispatch(
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
);
}
public connectedCallback() {
@@ -290,9 +297,7 @@ export class HaCodeEditor extends ReactiveElement {
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
],
});
@@ -304,20 +309,23 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint")) {
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
});
}
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
this._buildSchemaLinter()
),
});
}
@@ -371,6 +379,65 @@ export class HaCodeEditor extends ReactiveElement {
return this._loadedCodeMirror!.langs[this.mode];
}
private _buildSchemaLinter() {
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
return [];
}
const schema = this.yamlFieldSchema;
return [
this._loadedCodeMirror.linter(
(view) =>
this._loadedCodeMirror!.haYamlLintSource(
view,
schema,
this._i18n?.localize
),
{ delay: 500 }
),
];
}
/**
* Builds the yamlLintCompartment extensions: a linter that surfaces the
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
* either syntax linting or schema linting is active.
*
* Using a linter() instead of setDiagnostics() means this linter's
* diagnostics are managed independently of the schema linter's diagnostics
* they don't overwrite each other.
*/
private _buildYamlSyntaxLinter() {
if (this.readOnly) return [];
const showGutter = this.lint || !!this.yamlFieldSchema;
const extensions: Extension[] = [];
if (showGutter) {
extensions.push(this._loadedCodeMirror!.lintGutter());
}
if (this.lint) {
extensions.push(
this._loadedCodeMirror!.linter(
(view) => {
const err = this._yamlSyntaxError;
if (!err) return [];
const doc = view.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
return [
{ from: pos, to: line.to, severity: "error" as const, message },
];
},
{ delay: 0 }
)
);
}
return extensions;
}
private _createCodeMirror() {
if (!this._loadedCodeMirror) {
throw new Error("Cannot create editor before CodeMirror is loaded");
@@ -419,7 +486,10 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
this._buildYamlSyntaxLinter()
),
this._loadedCodeMirror.yamlSchemaCompartment.of(
this._buildSchemaLinter()
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
@@ -435,6 +505,21 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml"
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this.yamlFieldSchema
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
hassContext: this._hassArgHoverContext(),
})
: null,
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -442,6 +527,19 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml") {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource(() => ({
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
states: this._states,
devices: this._registries?.devices,
areas: this._registries?.areas,
floors: this._registries?.floors,
labels: this._labels,
}))
);
}
if (this.autocompleteEntities) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -452,6 +550,7 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
activateOnCompletion: (completion) => completion.type === "yaml-key",
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
@@ -1007,23 +1106,9 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
buildEntityCompletions(states)
);
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1418,18 +1503,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
buildDeviceCompletions(devices)
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
@@ -1448,17 +1522,7 @@ export class HaCodeEditor extends ReactiveElement {
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
@@ -1478,16 +1542,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
buildFloorCompletions(floors)
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
@@ -1507,16 +1562,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
buildLabelCompletions(labels)
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
@@ -16,13 +16,16 @@ import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"first",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
+11 -1
View File
@@ -8,6 +8,7 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
@@ -32,6 +33,14 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
/**
* Optional field schema for YAML mode. When provided, the code editor will
* offer field-aware key/value completions, hover tooltips, and linting.
* This is forwarded directly to ha-code-editor.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
@property({ attribute: false }) public defaultValue?: any;
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
@@ -123,8 +132,9 @@ export class HaYamlEditor extends LitElement {
.inDialog=${this.inDialog}
mode="yaml"
lint
autocomplete-entities
.autocompleteEntities=${!this.yamlFieldSchema}
autocomplete-icons
.yamlFieldSchema=${this.yamlFieldSchema}
.error=${this.isValid === false}
@value-changed=${this._onChange}
@editor-save=${this._onEditorSave}
+62
View File
@@ -0,0 +1,62 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { HaListItemBase } from "./ha-list-item-base";
/**
* @element ha-list-item-value
* @extends {HaListItemBase}
*
* @summary
* Non-interactive label/value row for grouped lists: label on the start
* side, value content end-aligned. The value is the default slot so callers
* can render rich content (links, secondary lines).
*
* @slot - The value content.
*
* @csspart label - The label column.
* @csspart value - The value column.
*
* @cssprop --ha-list-item-value-max-width - Maximum width of the value column. Defaults to 60%.
*
* @attr {string} label - The row label.
*/
@customElement("ha-list-item-value")
export class HaListItemValue extends HaListItemBase {
@property({ type: String }) public label?: string;
protected override _renderInner(): TemplateResult {
return html`
<div part="label" class="label">${this.label}</div>
<div part="value" class="value"><slot></slot></div>
`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
}
.label {
flex: 1;
color: var(--secondary-text-color);
}
.value {
max-width: var(--ha-list-item-value-max-width, 60%);
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-value": HaListItemValue;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { HaListBase } from "./ha-list-base";
/**
* @element ha-grouped-list
* @extends {HaListBase}
*
* @summary
* Grouped list: an optional header above a framed box of rows separated by
* hairlines the "grouped list" idiom of settings and detail views. Items
* are `<ha-list-item-*>` rows; use `ha-list-item-value` for label/value
* facts and `ha-list-item-button` for navigable rows.
*
* @slot - List items (`<ha-list-item-*>`).
*
* @csspart header - The header above the frame.
* @csspart base - The framed `<div role="list">`.
*
* @cssprop --ha-row-item-padding-inline - Horizontal padding of the rows, which the header aligns to. Defaults to `--ha-space-3`.
*
* @attr {string} header - Header text rendered above the frame.
*/
@customElement("ha-grouped-list")
export class HaGroupedList extends HaListBase {
// The frame carries the list role so the header stays out of the list
// semantics.
protected override readonly hostRole = "";
@property({ type: String }) public header?: string;
protected override render(): TemplateResult {
return html`
${
this.header
? html`<div part="header" class="header" id="header">
${this.header}
</div>`
: nothing
}
<div
part="base"
class="base"
role="list"
aria-labelledby=${ifDefined(this.header ? "header" : undefined)}
>
<slot></slot>
</div>
`;
}
static styles = [
...HaListBase.styles,
css`
:host {
--ha-row-item-padding-inline: var(--ha-space-3);
}
.header {
margin: 0 0 var(--ha-space-1);
margin-inline-start: calc(
var(--ha-row-item-padding-inline) + var(--ha-border-width-sm)
);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.base {
border: var(--ha-border-width-sm) solid var(--divider-color);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
overflow: hidden;
}
::slotted(:not(:first-child)) {
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-grouped-list": HaGroupedList;
}
}
+177 -76
View File
@@ -117,6 +117,22 @@ const literalTimeToSeconds = (value: unknown): number | undefined => {
return hours * 3600 + minutes * 60 + seconds;
};
const numericThresholdSuffix = (config: {
above?: number | string;
below?: number | string;
}): "above" | "below" | "above_below" | undefined => {
if (config.above !== undefined && config.below !== undefined) {
return "above_below";
}
if (config.above !== undefined) {
return "above";
}
if (config.below !== undefined) {
return "below";
}
return undefined;
};
const formatNumericLimitValue = (
hass: HomeAssistant,
value?: number | string
@@ -130,18 +146,26 @@ const formatNumericLimitValue = (
: value;
};
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Leave the entities out of the sentence, for rows that render them as
// target badges.
hideEntities?: boolean;
}
export const describeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
): string => {
try {
const description = tryDescribeTrigger(
trigger,
hass,
entityRegistry,
ignoreAlias
options
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -163,7 +187,7 @@ const tryDescribeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (isTriggerList(trigger)) {
const triggers = ensureArray(trigger.triggers);
@@ -179,14 +203,15 @@ const tryDescribeTrigger = (
});
}
if (trigger.alias && !ignoreAlias) {
if (trigger.alias && !options?.ignoreAlias) {
return trigger.alias;
}
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -210,7 +235,8 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
entityRegistry: EntityRegistryEntry[],
hideEntities = false
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -241,28 +267,16 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (trigger.trigger === "numeric_state" && trigger.entity_id) {
const entities: string[] = [];
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
const states = hass.states;
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
const attribute = trigger.attribute
? stateObj
? computeAttributeNameDisplay(
@@ -278,6 +292,39 @@ const describeLegacyTrigger = (
? describeDuration(hass.locale, trigger.for)
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
if (trigger.above !== undefined && trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
@@ -319,14 +366,14 @@ const describeLegacyTrigger = (
// State Trigger
if (trigger.trigger === "state") {
const entities: string[] = [];
const states = hass.states;
const entityArray: string[] = ensureArray(trigger.entity_id);
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
let attribute = "";
if (trigger.attribute) {
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
attribute = stateObj
? computeAttributeNameDisplay(
hass.localize,
@@ -337,17 +384,6 @@ const describeLegacyTrigger = (
: trigger.attribute;
}
const entityArray: string[] = ensureArray(trigger.entity_id);
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
const stateObj = hass.states[entityArray[0]] as HassEntity | undefined;
let fromChoice = "other";
let fromString = "";
if (trigger.from !== undefined) {
@@ -427,6 +463,32 @@ const describeLegacyTrigger = (
duration = describeDuration(hass.locale, trigger.for) ?? "";
}
if (hideEntities) {
return hass.localize(
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
toString: toString,
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
return hass.localize(
`${triggerTranslationBaseKey}.state.description.full`,
{
@@ -916,14 +978,14 @@ export const describeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
): string => {
try {
const description = tryDescribeCondition(
condition,
hass,
entityRegistry,
ignoreAlias
options
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -945,7 +1007,7 @@ const tryDescribeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (typeof condition === "string" && hasTemplate(condition)) {
return hass.localize(
@@ -953,7 +1015,7 @@ const tryDescribeCondition = (
);
}
if (condition.alias && !ignoreAlias) {
if (condition.alias && !options?.ignoreAlias) {
return condition.alias;
}
@@ -975,7 +1037,8 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -1001,7 +1064,8 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
entityRegistry: EntityRegistryEntry[],
hideEntities = false
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1058,17 +1122,20 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id) {
if (!condition.entity_id && !hideEntities) {
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.no_entity`
);
}
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
: condition.entity_id
] as HassEntity | undefined;
let attribute = "";
if (condition.attribute) {
const stateObj = Array.isArray(condition.entity_id)
? hass.states[condition.entity_id[0]]
: (hass.states[condition.entity_id] as HassEntity | undefined);
attribute = stateObj
? computeAttributeNameDisplay(
hass.localize,
@@ -1079,27 +1146,7 @@ const describeLegacyCondition = (
: condition.attribute;
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
const states: string[] = [];
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
: condition.entity_id
] as HassEntity | undefined;
if (Array.isArray(condition.state)) {
for (const state of condition.state.values()) {
states.push(
@@ -1116,7 +1163,7 @@ const describeLegacyCondition = (
: state
);
}
} else if (condition.state !== "") {
} else if (condition.state != null && condition.state !== "") {
states.push(
stateObj
? condition.attribute
@@ -1137,6 +1184,37 @@ const describeLegacyCondition = (
duration = describeDuration(hass.locale, condition.for) || "";
}
if (hideEntities) {
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.full`,
{
@@ -1159,15 +1237,14 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (condition.condition === "numeric_state" && condition.entity_id) {
const entity_ids = ensureArray(condition.entity_id);
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
const stateObj = hass.states[entity_ids[0]] as HassEntity | undefined;
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
const attribute = condition.attribute
? stateObj
@@ -1180,6 +1257,30 @@ const describeLegacyCondition = (
: condition.attribute
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
if (condition.above !== undefined && condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
+5 -2
View File
@@ -53,7 +53,8 @@ export const computeDeviceAreaLabel = (
translationMetadata: HomeAssistant["translationMetadata"],
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
): DeviceAreaLabel => {
const area = getDeviceArea(device, areas);
// Pass devices so a child device inherits its parent's area.
const area = getDeviceArea(device, areas, devices);
const viaDevice = device.via_device_id
? devices[device.via_device_id]
@@ -61,7 +62,9 @@ export const computeDeviceAreaLabel = (
const viaDeviceName = viaDevice
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
: undefined;
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
const viaDeviceArea = viaDevice
? getDeviceArea(viaDevice, areas, devices)
: undefined;
const viaDeviceAreaName = viaDeviceArea
? computeAreaName(viaDeviceArea)
: undefined;
+44 -1
View File
@@ -15,6 +15,13 @@ export {
subscribeDeviceRegistry,
} from "../ws-device_registry";
export type DeviceDisabler =
| "user"
| "integration"
| "config_entry"
// The device's parent device is disabled (child devices only).
| "device";
export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
@@ -33,11 +40,47 @@ export interface DeviceRegistryEntry extends RegistryEntry {
area_id: string | null;
name_by_user: string | null;
entry_type: "service" | null;
disabled_by: "user" | "integration" | "config_entry" | null;
disabled_by: DeviceDisabler | null;
configuration_url: string | null;
primary_config_entry: string | null;
// Set when this device is a child (logical part) of another device.
// null for regular top-level devices.
parent_device_id: string | null;
}
/**
* A child device as it arrives over the wire from
* `config/device_registry/list`. A child is a lightweight logical part of a
* parent device (e.g. an outlet of a power strip); it only carries its own
* fields and inherits the rest from its parent. It is never stored in
* `hass.devices` in this shape {@link resolveChildDevices} turns every child
* into a complete {@link DeviceRegistryEntry} at ingestion, so downstream code
* only ever sees full device entries.
*/
export interface ChildDeviceRegistryEntry extends RegistryEntry {
id: string;
config_entry_id: string;
config_subentry_id: string | null;
identifiers: [string, string][];
name: string | null;
name_by_user: string | null;
labels: string[];
area_id: string | null;
disabled_by: DeviceDisabler | null;
parent_device_id: string;
}
/**
* The raw, mixed list returned by `config/device_registry/list`: full devices
* and stripped children, discriminated by the presence of full-device fields.
*/
export type DeviceRegistryListEntry =
DeviceRegistryEntry | ChildDeviceRegistryEntry;
/** Whether a resolved device entry is a child (logical part) of another device. */
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
+79 -4
View File
@@ -2,12 +2,87 @@ import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { debounce } from "../common/util/debounce";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type {
ChildDeviceRegistryEntry,
DeviceRegistryEntry,
DeviceRegistryListEntry,
} from "./device/device_registry";
// A full device carries fields that stripped children never do; use one of
// those as the discriminant. This keeps "is a stripped child" decoupled from
// "has a parent", so a hypothetical full-featured sub-device would still be
// treated as a complete entry. We key off `connections` rather than
// `config_entries` because the latter is a deprecated compatibility field core
// plans to drop; `connections` is present on every full device and never on a
// stripped child.
const isChildEntry = (
entry: DeviceRegistryListEntry
): entry is ChildDeviceRegistryEntry => !("connections" in entry);
/**
* Resolve the mixed device list from `config/device_registry/list` into a flat
* list of complete {@link DeviceRegistryEntry} objects.
*
* Children are stripped over the wire and inherit the rest from their parent:
* - config-entry association comes from the child's own `config_entry_id`, so
* children still show up under their integration;
* - hardware/display fields (manufacturer, model, versions, ...) are inherited
* from the parent, since a child is a logical part of the same hardware;
* - identity fields (`connections`, `via_device_id`) are NOT inherited a
* child is not the parent and has no connections of its own.
*
* Nesting is a single level (core rejects a child as another child's parent),
* so no recursion is needed.
*/
export const resolveChildDevices = (
entries: DeviceRegistryListEntry[]
): DeviceRegistryEntry[] => {
const parents = new Map<string, DeviceRegistryEntry>();
for (const entry of entries) {
if (!isChildEntry(entry)) {
parents.set(entry.id, entry);
}
}
return entries.map((entry) => {
if (!isChildEntry(entry)) {
return entry;
}
const parent = parents.get(entry.parent_device_id);
return {
// Structural fields derived from the child's own config entry.
config_entries: [entry.config_entry_id],
config_entries_subentries: {
[entry.config_entry_id]: [entry.config_subentry_id],
},
primary_config_entry: entry.config_entry_id,
// Hardware/display fields inherited from the parent.
manufacturer: parent?.manufacturer ?? null,
model: parent?.model ?? null,
model_id: parent?.model_id ?? null,
sw_version: parent?.sw_version ?? null,
hw_version: parent?.hw_version ?? null,
serial_number: parent?.serial_number ?? null,
entry_type: parent?.entry_type ?? null,
configuration_url: parent?.configuration_url ?? null,
// Identity fields — a child has none of its own.
connections: [],
via_device_id: null,
// The child's own fields (id, name, area_id, labels, identifiers,
// parent_device_id, ...) win over everything above.
...entry,
};
});
};
export const fetchDeviceRegistry = (conn: Connection) =>
conn.sendMessagePromise<DeviceRegistryEntry[]>({
type: "config/device_registry/list",
});
conn
.sendMessagePromise<DeviceRegistryListEntry[]>({
type: "config/device_registry/list",
})
.then(resolveChildDevices);
const subscribeDeviceRegistryUpdates = (
conn: Connection,
+173 -93
View File
@@ -1,16 +1,24 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { HomeAssistant } from "../../types";
import "../../components/ha-yaml-editor";
import { computeDomain } from "../../common/entity/compute_domain";
@@ -26,6 +34,7 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
href?: string;
}
@customElement("ha-more-info-details")
@@ -40,8 +49,15 @@ class HaMoreInfoDetails extends LitElement {
@state() private _stateObj?: HassEntity;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
this.hass.loadBackendTranslation("title", [this.entry.platform]);
}
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
@@ -54,9 +70,93 @@ class HaMoreInfoDetails extends LitElement {
return nothing;
}
const { stateEntries, attributes, yamlData } = this._getDetailData(
this._stateObj
const {
stateEntries,
attributes,
yamlData: stateYamlData,
} = this._getDetailData(this._stateObj);
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
const integrationName = this.entry?.platform
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
});
}
if (area && areaName) {
contextEntries.push({
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
});
}
if (this.entry?.platform && integrationName) {
contextEntries.push({
translationKey: "ui.components.related-items.integration",
value: integrationName,
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
});
}
const entityEntries: DetailEntry[] = [
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
},
];
const yamlData = {
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
entity_id: this.params.entityId,
labels: labelNames,
},
...stateYamlData,
};
return html`
<div class="content">
@@ -69,43 +169,41 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="data-group">
${stateEntries.map(
(entry) =>
html`<div class="data-entry">
<div class="key">
${this.hass.localize(entry.translationKey)}
</div>
<div class="value">${entry.value}</div>
</div>`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
</div>
</div>
</ha-card>
</section>
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="data-group">
${this._renderAttributes(attributes)}
</div>
</div>
</ha-card>
</section>
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
>
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
>
${this._renderAttributes(attributes)}
</ha-grouped-list>
`
}
</div>
@@ -177,6 +275,20 @@ class HaMoreInfoDetails extends LitElement {
: value;
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map(
(entry) => html`
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
${
entry.href
? html`<a href=${entry.href}>${entry.value}</a>`
: entry.value
}
</ha-list-item-value>
`
);
}
private _renderAttributes(attributes: string[]) {
if (attributes.length === 0) {
return html`<div class="empty">
@@ -192,28 +304,25 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
${
attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
}
</div>
</div>
<ha-list-item-value
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
>
${
attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
}
</ha-list-item-value>
`
);
}
@@ -247,47 +356,18 @@ class HaMoreInfoDetails extends LitElement {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
.section + .section {
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-4);
}
.section-title {
margin: 0 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-group .data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
a {
color: var(--primary-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-2) 0;
padding: var(--ha-space-3) var(--ha-space-4);
}
`;
}
+4 -3
View File
@@ -60,6 +60,7 @@ import type { HomeAssistant } from "../../types";
import { isIosApp } from "../../util/is_ios";
import { isMac } from "../../util/is_mac";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import "../restart/automation-restart-status";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { showVoiceCommandDialog } from "../voice-command-dialog/show-ha-voice-command-dialog";
import {
@@ -799,9 +800,9 @@ export class QuickBar extends LitElement {
title: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_title`
),
text: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_description`
),
text: html`${this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_description`
)}<br /><br /><automation-restart-status></automation-restart-status>`,
confirmText: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_action`
),
@@ -0,0 +1,54 @@
import { LitElement, html } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import { computeDomain } from "../../common/entity/compute_domain";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import {
formattersContext,
internationalizationContext,
statesContext,
} from "../../data/context";
const ENTITY_NAME_FORMAT: EntityNameItem[] = [
{ type: "entity" },
{ type: "area" },
] as const;
const ENTITY_NAME_OPTIONS = { separator: STRINGS_SEPARATOR_DOT } as const;
@customElement("automation-restart-status")
class AutomationRestartStatus extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
protected render() {
const automations = Object.values(this._states).filter((s) => {
const domain = computeDomain(s.entity_id);
return (
(domain === "script" || domain === "automation") && s.attributes.current
);
});
return automations.length
? html`${this._i18n.localize("ui.dialogs.restart.interrupt_automations")}
<ul>
${automations.map((a) => html`<li>${this._formatters.formatEntityName(a, ENTITY_NAME_FORMAT, ENTITY_NAME_OPTIONS)}</li>`)}
</ul>`
: html`${this._i18n.localize("ui.dialogs.restart.no_interrupt_automations")}`;
}
}
declare global {
interface HTMLElementTagNameMap {
"automation-restart-status": AutomationRestartStatus;
}
}
+7 -6
View File
@@ -41,6 +41,7 @@ import {
showConfirmationDialog,
} from "../generic/show-dialog-box";
import { showRestartWaitDialog } from "./show-dialog-restart";
import "./automation-restart-status";
@customElement("dialog-restart")
class DialogRestart extends LitElement {
@@ -357,12 +358,12 @@ class DialogRestart extends LitElement {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`),
text: html`${this.hass.localize(
`ui.dialogs.restart.${action}.confirm_description`
)}${
backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing
}`,
`ui.dialogs.restart.${action}.confirm_description`
)}${
backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing
} <br /><br /><automation-restart-status></automation-restart-status>`,
confirmText: this.hass.localize(
`ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
),
+69 -11
View File
@@ -6,9 +6,13 @@ import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-analytics";
import "../components/ha-button";
import "../components/ha-spinner";
import "../components/ha-svg-icon";
import type { Analytics } from "../data/analytics";
import { setAnalyticsPreferences } from "../data/analytics";
import {
getAnalyticsDetails,
setAnalyticsPreferences,
} from "../data/analytics";
import { onboardAnalyticsStep } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
@@ -22,9 +26,11 @@ class OnboardingAnalytics extends LitElement {
@state() private _error?: string;
@state() private _analyticsDetails: Analytics = {
preferences: {},
};
// Undefined while we are still waiting for the analytics integration to be
// set up (Home Assistant may still be starting up during onboarding).
@state() private _analyticsDetails?: Analytics;
private _retryTimeout?: number;
protected render(): TemplateResult {
return html`
@@ -40,13 +46,26 @@ class OnboardingAnalytics extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
${
this._analyticsDetails
? html`
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
`
: html`
<div class="loading">
<ha-spinner></ha-spinner>
<p>
${this.localize("ui.panel.page-onboarding.analytics.waiting")}
</p>
</div>
`
}
${this._error ? html`<div class="error">${this._error}</div>` : ""}
<div class="footer">
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
@@ -63,6 +82,35 @@ class OnboardingAnalytics extends LitElement {
this._save(ev);
}
});
this._loadAnalyticsDetails();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._retryTimeout) {
clearTimeout(this._retryTimeout);
this._retryTimeout = undefined;
}
}
private async _loadAnalyticsDetails(): Promise<void> {
try {
// The analytics integration registers its WebSocket commands during
// setup, but only stores its data once the config entry is set up. On a
// fresh install we can reach this step before that happened, so keep
// retrying until it is ready instead of failing on save.
this._analyticsDetails = await getAnalyticsDetails(this.hass);
this._error = undefined;
} catch (err: any) {
if (err.code === "not_found") {
this._retryTimeout = window.setTimeout(
() => this._loadAnalyticsDetails(),
1000
);
return;
}
this._error = err.message;
}
}
private _preferencesChanged(
@@ -76,6 +124,9 @@ class OnboardingAnalytics extends LitElement {
private async _save(ev) {
ev.preventDefault();
if (!this._analyticsDetails) {
return;
}
try {
await setAnalyticsPreferences(
this.hass,
@@ -98,6 +149,13 @@ class OnboardingAnalytics extends LitElement {
.error {
color: var(--error-color);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 0;
}
a {
color: var(--primary-color);
text-decoration: none;
@@ -1,6 +1,7 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -8,6 +9,7 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
import { migrateAutomationAction, type Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { actionSchemaKey, actionToYamlSchema } from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import {
@@ -41,6 +43,14 @@ export default class HaAutomationActionEditor extends LitElement {
@query(COLLAPSIBLE_ACTION_ELEMENTS.join(", "))
private _collapsibleElement?: ActionElement;
private _actionYamlSchema = memoizeOne(
(
actionKey: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => actionToYamlSchema(actionKey, services, localize)
);
protected render() {
const yamlMode = this.yamlMode || !this.uiSupported;
const type = getAutomationActionType(this.action);
@@ -74,6 +84,11 @@ export default class HaAutomationActionEditor extends LitElement {
.defaultValue=${this.action}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._actionYamlSchema(
actionSchemaKey(this.action),
this.hass.services,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -11,6 +11,10 @@ import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import {
builtInConditionSchema,
conditionDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import type { ConditionElement } from "./ha-automation-condition-row";
@@ -44,6 +48,22 @@ export default class HaAutomationConditionEditor extends LitElement {
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
private _collapsibleElement?: ConditionElement;
// Memoized on the condition type rather than the condition itself: the
// condition object gets a new identity on every keystroke, and the schema
// only depends on its type.
private _conditionYamlSchema = memoizeOne(
(
conditionType: string,
description: ConditionDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (!description) {
return builtInConditionSchema(conditionType, localize);
}
return conditionDescriptionToSchema(conditionType, description, localize);
}
);
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
@@ -82,6 +102,11 @@ export default class HaAutomationConditionEditor extends LitElement {
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._conditionYamlSchema(
condition.condition,
this.description,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -52,7 +52,6 @@ import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -64,7 +63,6 @@ import {
type ValidConfig,
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceCondition } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
@@ -76,6 +74,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
@@ -182,12 +182,14 @@ export default class HaAutomationConditionRow extends LitElement {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const target = descriptionHasTarget
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
@@ -224,13 +226,15 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${
target !== undefined || (descriptionHasTarget && !this._isNew)
target !== undefined || targetRequired
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
@@ -600,6 +604,30 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -777,7 +805,9 @@ export default class HaAutomationConditionRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, true)
describeCondition(this.condition, this.hass, this._entityReg, {
ignoreAlias: true,
})
),
defaultValue: this.condition.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -32,6 +32,54 @@ const numericStateConditionStruct = object({
enabled: optional(boolean()),
});
export const YAML_SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: { attribute: { hide_attributes: NON_NUMERIC_ATTRIBUTES } },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-numeric_state")
export default class HaNumericStateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -242,20 +290,7 @@ export default class HaNumericStateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -19,6 +19,7 @@ import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { forDictStruct } from "../../structs";
import type { ConditionElement } from "../ha-automation-condition-row";
@@ -34,7 +35,7 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
const SCHEMA = [
export const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
@@ -61,6 +62,26 @@ const SCHEMA = [
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.conditions.type.state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -125,24 +146,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -14,6 +14,29 @@ type FormType = "before" | "after" | "between";
const BEFORE_DEFAULT = "sunrise";
const AFTER_DEFAULT = "sunset";
export const YAML_SCHEMA = [
{
name: "before",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "before_offset", selector: { duration: { allow_negative: true } } },
{
name: "after",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "after_offset", selector: { duration: { allow_negative: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.sun.${fieldName}` as any
);
@customElement("ha-automation-condition-sun")
export class HaSunCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -154,10 +177,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (schema: {
name: "before" | "after";
}): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.sun.${schema.name}`
);
}): string => computeLabel(schema.name, this.hass.localize);
private _typeSelected(ev: HaSelectSelectEvent): void {
const value = ev.detail.value as FormType;
@@ -5,11 +5,20 @@ import type { HomeAssistant } from "../../../../../types";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
const SCHEMA = [
export const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.template.${fieldName}` as any
);
@customElement("ha-automation-condition-template")
export class HaTemplateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -43,10 +52,7 @@ export class HaTemplateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -13,6 +13,24 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
export const YAML_SCHEMA = [
{ name: "after", selector: { time: {} } },
{ name: "before", selector: { time: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.time.${fieldName}` as any
);
@customElement("ha-automation-condition-time")
export class HaTimeCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -184,10 +202,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.time.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -13,6 +13,7 @@ import {
type Trigger,
type TriggerCondition,
} from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const getTriggersIds = (triggers: Trigger[]): string[] => {
@@ -22,6 +23,20 @@ const getTriggersIds = (triggers: Trigger[]): string[] => {
return Array.from(new Set(triggerIds));
};
// Static YAML schema — trigger IDs are dynamic at runtime, so we use a
// plain text selector here to at least provide key completion.
export const YAML_SCHEMA = [
{ name: "id", required: true, selector: { text: { multiple: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${fieldName}` as any
);
@customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -94,10 +109,7 @@ export class HaTriggerCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
private _automationUpdated(config?: AutomationConfig) {
this._triggerIds = config?.triggers
@@ -0,0 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
export const getDeviceTarget = (
deviceId?: string
): HassServiceTarget | undefined =>
deviceId ? { device_id: [deviceId] } : undefined;
@@ -0,0 +1,9 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../../common/array/ensure-array";
export const getEntityTarget = (
entityId?: string | string[]
): HassServiceTarget | undefined => {
const entityIds = entityId ? ensureArray(entityId).filter(Boolean) : [];
return entityIds.length ? { entity_id: entityIds } : undefined;
};
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -13,6 +14,10 @@ import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
builtInTriggerSchema,
triggerDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import "./types/ha-automation-trigger-platform";
@@ -37,6 +42,23 @@ export default class HaAutomationTriggerEditor extends LitElement {
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
// Memoized on the trigger type rather than the trigger itself: the trigger
// object gets a new identity on every keystroke, and the schema only depends
// on its type.
private _triggerYamlSchema = memoizeOne(
(
triggerType: string | undefined,
description: TriggerDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (triggerType === undefined) return undefined;
if (!description) {
return builtInTriggerSchema(triggerType, localize);
}
return triggerDescriptionToSchema(triggerType, description, localize);
}
);
protected render() {
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
@@ -72,6 +94,13 @@ export default class HaAutomationTriggerEditor extends LitElement {
<ha-yaml-editor
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._triggerYamlSchema(
isTriggerList(this.trigger)
? undefined
: this.trigger.trigger,
this.description,
this.hass.localize
)}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
@@ -60,7 +60,6 @@ import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import type { TriggerDescriptions } from "../../../../data/trigger";
@@ -74,6 +73,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
@@ -214,11 +215,12 @@ export default class HaAutomationTriggerRow extends LitElement {
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger];
const target = descriptionHasTarget
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
const hasEntityTarget = type === "state" || type === "numeric_state";
const target = this._getTarget(type, descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const triggerTargetSpec =
type === "platform"
@@ -248,12 +250,16 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`
}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${
target !== undefined || (descriptionHasTarget && !this._isNew)
target !== undefined || targetRequired
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
targetRequired,
triggerTargetSpec,
type !== "device"
)
@@ -595,6 +601,27 @@ export default class HaAutomationTriggerRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
type: string,
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.trigger) {
return this.trigger.target;
}
if (hasEntityTarget && "entity_id" in this.trigger) {
return this._getEntityTarget(this.trigger.entity_id);
}
if (type === "device" && "device_id" in this.trigger) {
return this._getDeviceTarget(this.trigger.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -857,7 +884,9 @@ export default class HaAutomationTriggerRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, true)
describeTrigger(this.trigger, this.hass, this._entityReg, {
ignoreAlias: true,
})
),
defaultValue: this.trigger.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -11,6 +11,37 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "event":
return localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
default:
return "";
}
};
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { domain: "calendar" } },
},
{
name: "event",
type: "select" as const,
required: true,
options: [["start", "start"] as const, ["end", "end"] as const],
},
{ name: "offset", selector: { text: {} } },
] as const;
@customElement("ha-automation-trigger-calendar")
export class HaCalendarTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -125,17 +156,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "event":
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
}
return "";
};
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -8,6 +8,25 @@ import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.geo_location.${fieldName}` as any
);
export const YAML_SCHEMA = [
{ name: "source", selector: { text: {} } },
{ name: "zone", selector: { entity: { domain: "zone" } } },
{
name: "event",
type: "select" as const,
required: true,
options: [["enter", "enter"] as const, ["leave", "leave"] as const],
},
] as const;
@customElement("ha-automation-trigger-geo_location")
export class HaGeolocationTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -73,10 +92,7 @@ export class HaGeolocationTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.geo_location.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -8,6 +8,23 @@ import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.homeassistant.${fieldName}` as any
);
export const YAML_SCHEMA = [
{
name: "event",
type: "select" as const,
required: true,
options: [["start", "start"] as const, ["shutdown", "shutdown"] as const],
},
] as const;
@customElement("ha-automation-trigger-homeassistant")
export class HaHassTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -69,10 +86,7 @@ export class HaHassTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.homeassistant.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
label {
@@ -8,6 +8,7 @@ import { hasTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
@@ -183,6 +184,63 @@ const SCHEMA = [
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
selector: { attribute: {} },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
{ name: "for", selector: { duration: {} } },
] as const;
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -276,24 +334,7 @@ export class HaNumericStateTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -12,9 +12,31 @@ import type { PersistentNotificationTrigger } from "../../../../../data/automati
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${fieldName}` as any
);
const DEFAULT_UPDATE_TYPES = ["added", "removed"];
const DEFAULT_NOTIFICATION_ID = "";
export const YAML_SCHEMA = [
{ name: "notification_id", selector: { text: {} } },
{
name: "update_type",
type: "multi_select" as const,
options: [
["added", "added"] as const,
["removed", "removed"] as const,
["current", "current"] as const,
["updated", "updated"] as const,
],
},
] as const;
@customElement("ha-automation-trigger-persistent_notification")
export class HaPersistentNotificationTrigger
extends LitElement
@@ -99,10 +121,7 @@ export class HaPersistentNotificationTrigger
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -43,6 +43,40 @@ const stateTriggerStruct = assign(
})
);
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
selector: { attribute: {} },
context: { filter_entity: "entity_id" },
},
{
name: "from",
selector: { state: { multiple: true } },
context: { filter_entity: "entity_id" },
},
{
name: "to",
selector: { state: { multiple: true } },
context: { filter_entity: "entity_id" },
},
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
fieldName === "entity_id"
? "ui.components.entity.entity-picker.entity"
: (`ui.panel.config.automation.editor.triggers.type.state.${fieldName}` as any)
);
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -338,12 +372,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
schema.name === "entity_id"
? "ui.components.entity.entity-picker.entity"
: `ui.panel.config.automation.editor.triggers.type.state.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -9,6 +9,24 @@ import "../../../../../components/ha-form/ha-form";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.sun.${fieldName}` as any
);
export const YAML_SCHEMA = [
{
name: "event",
type: "select" as const,
required: true,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "offset", selector: { text: {} } },
] as const;
@customElement("ha-automation-trigger-sun")
export class HaSunTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -73,10 +91,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.sun.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -7,8 +7,9 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
const SCHEMA = [
export const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
{
name: "for",
@@ -25,6 +26,14 @@ const SCHEMA = [
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.template.${fieldName}` as any
);
@customElement("ha-automation-trigger-template")
export class HaTemplateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -102,10 +111,7 @@ export class HaTemplateTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.template.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -18,6 +18,37 @@ const MODE_ENTITY = "entity";
const VALID_DOMAINS = ["sensor", "input_datetime"];
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
// Real YAML keys for the time trigger (the UI uses time/entity/mode/offset
// as an abstraction, but the actual YAML uses `at` and `weekday`).
export const YAML_SCHEMA = [
{ name: "at", required: true, selector: { text: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "time":
return localize(
"ui.panel.config.automation.editor.triggers.type.time.at"
);
case "weekday":
return localize(
"ui.panel.config.automation.editor.triggers.type.time.weekday"
);
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.time.${fieldName}` as any
);
}
};
@customElement("ha-automation-trigger-time")
export class HaTimeTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -207,21 +238,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "time":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.at`
);
case "weekday":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.weekday`
);
}
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.${schema.name}`
);
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -6,13 +6,28 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { TimePatternTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
const SCHEMA = [
export const SCHEMA = [
{ name: "hours", selector: { text: {} } },
{ name: "minutes", selector: { text: {} } },
{ name: "seconds", selector: { text: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${fieldName}` as any
);
export const computeHelper = (
_fieldName: string,
localize: LocalizeFunc
): string =>
localize("ui.panel.config.automation.editor.triggers.type.time_pattern.help");
@customElement("ha-automation-trigger-time_pattern")
export class HaTimePatternTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -47,17 +62,11 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
private _computeHelperCallback = (
_schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
);
schema: SchemaUnion<typeof SCHEMA>
): string => computeHelper(schema.name, this.hass.localize);
}
declare global {
File diff suppressed because it is too large Load Diff
@@ -53,12 +53,11 @@ interface UpdateGroup {
key: string;
title: string;
entities: UpdateEntity[];
showUpdateAll: boolean;
showUpdateButton: boolean;
}
const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
const INTEGRATIONS_KEY = "__integrations__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -215,7 +214,7 @@ class HaConfigSectionUpdates extends LitElement {
${group.title}
</div>
${
group.showUpdateAll
group.showUpdateButton
? html`
<ha-button
appearance="plain"
@@ -224,10 +223,12 @@ class HaConfigSectionUpdates extends LitElement {
.disabled=${group.entities.every((entity) =>
updateIsInstalling(entity)
)}
@click=${this._updateAll}
@click=${this._updateGroup}
>
${this.hass.localize(
"ui.panel.config.updates.update_all"
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
)}
</ha-button>
`
@@ -347,7 +348,7 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}
private async _updateAll(ev: Event) {
private async _updateGroup(ev: Event) {
const group = (ev.currentTarget as any).group as UpdateGroup;
const entityIds = group.entities
.filter((entity) => !updateIsInstalling(entity))
@@ -413,7 +414,6 @@ class HaConfigSectionUpdates extends LitElement {
const systemEntities: UpdateEntity[] = [];
const appEntities: UpdateEntity[] = [];
const byDomain = new Map<string, UpdateEntity[]>();
const otherIntegrationEntities: UpdateEntity[] = [];
for (const entity of entities) {
if (isSystemUpdate(entity)) {
@@ -422,36 +422,29 @@ class HaConfigSectionUpdates extends LitElement {
}
const domain =
entitySources?.[entity.entity_id]?.domain ??
entityRegistry[entity.entity_id]?.platform;
entityRegistry[entity.entity_id]?.platform ??
"unknown";
if (domain === "hassio") {
appEntities.push(entity);
continue;
}
if (!domain) {
otherIntegrationEntities.push(entity);
continue;
}
if (!byDomain.has(domain)) {
byDomain.set(domain, []);
}
byDomain.get(domain)!.push(entity);
}
const multiInstanceGroups: UpdateGroup[] = [];
const integrationGroups: UpdateGroup[] = [];
byDomain.forEach((entries, domain) => {
if (entries.length >= 2) {
multiInstanceGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateAll: true,
});
} else {
otherIntegrationEntities.push(...entries);
}
integrationGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateButton: true,
});
});
multiInstanceGroups.sort((a, b) =>
integrationGroups.sort((a, b) =>
caseInsensitiveStringCompare(a.title, b.title, language)
);
@@ -462,27 +455,18 @@ class HaConfigSectionUpdates extends LitElement {
key: SYSTEM_KEY,
title: localize("ui.panel.config.updates.group_system"),
entities: systemEntities,
showUpdateAll: false,
showUpdateButton: false,
});
}
groups.push(...multiInstanceGroups);
if (otherIntegrationEntities.length) {
groups.push({
key: INTEGRATIONS_KEY,
title: localize("ui.panel.config.updates.group_integrations"),
entities: otherIntegrationEntities,
showUpdateAll: true,
});
}
groups.push(...integrationGroups);
if (appEntities.length) {
groups.push({
key: APPS_KEY,
title: localize("ui.panel.config.updates.group_apps"),
entities: appEntities,
showUpdateAll: true,
showUpdateButton: true,
});
}
@@ -88,7 +88,7 @@ class HaConfigUpdates extends LitElement {
const areaName =
deviceEntry && deviceEntry.entry_type !== "service"
? getDeviceArea(deviceEntry, this._areas)?.name ||
? getDeviceArea(deviceEntry, this._areas, this._devices)?.name ||
this._localize("ui.panel.config.updates.no_area")
: undefined;
@@ -75,7 +75,11 @@ export class HaDeviceViaDevicesCard extends LitElement {
? viaDevices
: viaDevices.slice(0, MAX_VISIBLE_VIA_DEVICES)
).map((viaDevice) => {
const area = getDeviceArea(viaDevice, this.hass.areas);
const area = getDeviceArea(
viaDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[viaDevice.id] ?? 0;
const secondary = [
area?.name,
@@ -125,7 +125,10 @@ class DialogDeviceRegistryDetail extends DirtyStateProviderMixin<DeviceFormState
<div class="row">
<ha-switch
.checked=${!this._disabledBy}
.disabled=${this._params.device.disabled_by === "config_entry"}
.disabled=${
this._params.device.disabled_by === "config_entry" ||
this._params.device.disabled_by === "device"
}
@change=${this._disabledByChanged}
>
</ha-switch>
@@ -33,6 +33,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
@@ -442,7 +443,7 @@ export class HaConfigDevicePage extends LitElement {
const batteryChargingState = batteryChargingEntity
? this.hass.states[batteryChargingEntity.entity_id]
: undefined;
const area = device.area_id ? this.hass.areas[device.area_id] : undefined;
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const deviceInfo: TemplateResult[] = integrations.length
? [
@@ -486,9 +486,13 @@ export class HaConfigDeviceDashboard extends LitElement {
);
const floorArea =
getDeviceArea(device, areas) ??
getDeviceArea(device, areas, this.hass.devices) ??
(device.via_device_id && this.hass.devices[device.via_device_id]
? getDeviceArea(this.hass.devices[device.via_device_id], areas)
? getDeviceArea(
this.hass.devices[device.via_device_id],
areas,
this.hass.devices
)
: undefined);
const floorId = floorArea?.floor_id;
const floorName =
@@ -53,7 +53,7 @@ class HaConfigEntryDeviceRow extends LitElement {
const entities = this._getEntities();
const area = getDeviceArea(device, this.hass.areas);
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
@@ -240,7 +240,7 @@ export class BluetoothNetworkVisualization extends LitElement {
const scannerDevice = this._sourceDevices[scanner.source] as
DeviceRegistryEntry | undefined;
const area = scannerDevice
? getDeviceArea(scannerDevice, this.hass.areas)
? getDeviceArea(scannerDevice, this.hass.areas, this.hass.devices)
: undefined;
nodes.push({
id: scanner.source,
@@ -282,7 +282,7 @@ export class BluetoothNetworkVisualization extends LitElement {
const device = this._sourceDevices[node.address] as
DeviceRegistryEntry | undefined;
const area = device
? getDeviceArea(device, this.hass.areas)
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
nodes.push({
id: node.address,
@@ -350,7 +350,9 @@ export class BluetoothNetworkVisualization extends LitElement {
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
const device = this._sourceDevices[address];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const areaLine = area
? html`<br /><b
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
@@ -64,7 +64,9 @@ export function createZHANetworkChartData(
const haDevice = hass.devices[device.device_reg_id] as
DeviceRegistryEntry | undefined;
const area = haDevice ? getDeviceArea(haDevice, hass.areas) : undefined;
const area = haDevice
? getDeviceArea(haDevice, hass.areas, hass.devices)
: undefined;
// Create node
nodes.push({
id: device.ieee,
@@ -162,7 +162,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
const haDevice = this.hass.devices[device.device_reg_id] as
DeviceRegistryEntry | undefined;
const area = haDevice
? getDeviceArea(haDevice, this.hass.areas)
? getDeviceArea(haDevice, this.hass.areas, this.hass.devices)
: undefined;
return html`<b>IEEE: </b>${device.ieee}<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
@@ -177,7 +177,11 @@ class DialogZWaveJSRebuildNetworkRoutesDetail extends DialogMixin<ZWaveJSRebuild
) ||
this._i18n.localize("ui.components.device-picker.unnamed_device");
const area = getDeviceArea(device, this._registries.areas);
const area = getDeviceArea(
device,
this._registries.areas,
this._registries.devices
);
const areaName = area ? computeAreaName(area) : undefined;
@@ -183,7 +183,9 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
const { id, name } = data as any;
const device = this._devices[id] as DeviceRegistryEntry | undefined;
const nodeStatus = this._nodeStatuses[id];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
return html`<ha-chart-tooltip-marker
.color=${String((params as CallbackDataParams).color ?? "")}
></ha-chart-tooltip-marker>
@@ -295,7 +297,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
const device = this._devices[node.node_id] as
DeviceRegistryEntry | undefined;
const area = device
? getDeviceArea(device, this.hass.areas)
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
nodes.push({
id: String(node.node_id),
@@ -50,6 +50,7 @@ import { documentationUrl } from "../../../../util/documentation-url";
import { resolveMediaSource } from "../../../../data/media_source";
import { MatchMinHeightMixin } from "../../../../mixins/match-min-height-mixin";
import { withViewTransition } from "../../../../common/util/view-transition";
import { serviceActionSchema } from "../../automation/yaml_schema_helpers";
@customElement("tools-action")
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
@@ -197,6 +198,11 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
<ha-yaml-editor
id="yaml-editor"
.defaultValue=${this._serviceData}
.yamlFieldSchema=${this._yamlFieldSchema(
this._serviceData?.action,
this.hass.services,
this.hass.localize
)}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
</div>`
@@ -397,6 +403,20 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
fields.filter((field) => !field.selector)
);
private _yamlFieldSchema = memoizeOne(
(
action: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => {
if (!action) return undefined;
const domain = computeDomain(action);
const service = computeObjectId(action);
if (!domain || !service) return undefined;
return serviceActionSchema(domain, service, services, localize);
}
);
private _validateServiceData = (
serviceData: ServiceAction | undefined,
fields,
@@ -92,7 +92,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
</div>
`
: html`
<div class="flex state">
<div class="flex box">
<ha-input
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
@@ -128,6 +128,10 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
min-width: 45px;
text-align: end;
}
.box {
flex-grow: 0;
min-width: 45px;
}
ha-input {
width: 100%;
}
@@ -99,7 +99,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
</div>
`
: html`
<div class="flex state">
<div class="flex box">
<ha-input
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
@@ -136,6 +136,10 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
min-width: 45px;
text-align: end;
}
.box {
flex-grow: 0;
min-width: 45px;
}
ha-input::part(wa-input) {
text-align: end;
direction: ltr !important;
+18 -1
View File
@@ -37,7 +37,13 @@ export {
search,
searchKeymap,
} from "@codemirror/search";
export { lintGutter, lintKeymap, setDiagnostics } from "@codemirror/lint";
export {
lintGutter,
lintKeymap,
setDiagnostics,
linter,
forceLinting,
} from "@codemirror/lint";
export { EditorState } from "@codemirror/state";
export {
crosshairCursor,
@@ -86,12 +92,23 @@ export {
JINJA_FUNCTION_ARG_TYPES,
} from "./jinja_ha_completions";
export type { HassArgHoverContext, JinjaArgType } from "./jinja_ha_completions";
export {
haYamlCompletionSource,
haYamlHoverSource,
haYamlLintSource,
} from "./yaml_ha_completions";
export type {
HaYamlCompletionContext,
HaYamlHoverContext,
} from "./yaml_ha_completions";
export type { YamlFieldSchemaMap, YamlFieldSchema } from "./yaml_field_schema";
export { closePercentBrace };
export const langCompartment = new Compartment();
export const readonlyCompartment = new Compartment();
export const linewrapCompartment = new Compartment();
export const yamlLintCompartment = new Compartment();
export const yamlSchemaCompartment = new Compartment();
// ---------------------------------------------------------------------------
// YAML scalar type highlighter
+122
View File
@@ -0,0 +1,122 @@
/**
* Shared CodeMirror completion-item builders for HA entity / device / area
* selectors.
*
* Used by both the Jinja template editor (`ha-code-editor.ts`) and the YAML
* field-schema editor (`yaml_ha_completions.ts`) so the two always produce
* identical completion items for the same HA registry data.
*
* Each builder follows the same convention:
* label "friendly name + ID" concatenated so filtering works on both
* displayLabel only the friendly name (what the user actually sees)
* detail the raw ID (shown as secondary text)
* apply the raw ID (what gets inserted)
*/
import type { Completion } from "@codemirror/autocomplete";
import type { HassEntities } from "home-assistant-js-websocket";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import type { AreaRegistryEntry } from "../data/area/area_registry";
import type { DeviceRegistryEntry } from "../data/device/device_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import type { LabelRegistryEntry } from "../data/label/label_registry";
/**
* Build completion items for entity IDs.
* `label` is "friendly name + entity_id" for search; `displayLabel` shows only
* the entity_id; `detail` shows the friendly name.
*/
export function buildEntityCompletions(states: HassEntities): Completion[] {
return Object.keys(states).map((entityId) => {
const friendlyName = states[entityId].attributes.friendly_name as
string | undefined;
return {
type: "variable",
label: friendlyName ? `${friendlyName} ${entityId}` : entityId,
displayLabel: entityId,
detail: friendlyName,
apply: entityId,
};
});
}
/**
* Build completion items for device IDs.
* `label` is "name + id" for search; `displayLabel` shows only the name;
* `detail` shows the device ID.
*/
export function buildDeviceCompletions(
devices: Record<string, DeviceRegistryEntry>
): Completion[] {
return Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device) ?? device.id;
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name,
detail: device.id,
apply: device.id,
};
});
}
/**
* Build completion items for area IDs.
* `label` is "name + area_id" for search; `displayLabel` shows only the name;
* `detail` shows the area ID.
*/
export function buildAreaCompletions(
areas: Record<string, AreaRegistryEntry>
): Completion[] {
return Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`,
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
});
}
/**
* Build completion items for floor IDs.
* `label` is "name + floor_id" for search; `displayLabel` shows only the name;
* `detail` shows the floor ID.
*/
export function buildFloorCompletions(
floors: Record<string, FloorRegistryEntry>
): Completion[] {
return Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`,
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
});
}
/**
* Build completion items for label IDs.
* `label` is "name + label_id" for search; `displayLabel` shows only the name;
* `detail` shows the label ID.
*/
export function buildLabelCompletions(
labels: LabelRegistryEntry[]
): Completion[] {
return labels.map((label) => ({
type: "variable",
label: `${label.name} ${label.label_id}`,
displayLabel: label.name,
detail: label.label_id,
apply: label.label_id,
}));
}
+1 -1
View File
@@ -1952,7 +1952,7 @@ function buildTooltipDom(
* Returns null when no hass context is available or the value can't be resolved.
* `siblingEntityId` is only used for `attribute` arg types.
*/
function buildArgTooltipDom(
export function buildArgTooltipDom(
argType: JinjaArgType,
value: string,
ctx: HassArgHoverContext,
+67
View File
@@ -0,0 +1,67 @@
import type { Selector } from "../data/selector";
/**
* Describes a single field in a YAML schema used for editor assistance
* (completions, hover tooltips, and linting) in ha-yaml-editor / ha-code-editor.
*
* This is intentionally kept separate from superstruct structs (which are
* runtime-validation only) and from ha-form schemas (which drive form UI).
* It maps closely to the shape of TriggerDescription.fields,
* ConditionDescription.fields, and HassService.fields so the automation editor
* can forward those descriptions into the YAML editor with minimal conversion.
*/
export interface YamlFieldSchema {
/** Human-readable description shown in the hover tooltip. */
description?: string;
/**
* Selector driving value completions and type hints. When present,
* the completion source will offer relevant value suggestions based on
* the selector type (boolean true/false, select option list, etc.).
*/
selector?: Selector;
/** Whether the field is required (shown in the hover tooltip). */
required?: boolean;
/** Example value shown in the hover tooltip. */
example?: unknown;
/** Default value shown in the hover tooltip. */
default?: unknown;
/**
* Nested field schema for object/mapping values. When set, drilling
* into this key's value will offer the nested fields as completions.
*/
fields?: YamlFieldSchemaMap;
}
/**
* A map of YAML key field schema. Passed to ha-yaml-editor / ha-code-editor.
*
* Keys are the YAML keys valid at this mapping level; see
* `allowUnknownFields()` for levels where that set is not statically known.
*/
export type YamlFieldSchemaMap = Record<string, YamlFieldSchema>;
/**
* Marks a map as accepting keys beyond the ones it lists. Kept on a symbol so
* it can never collide with a real YAML key, and so it stays out of the
* Object.keys() / Object.entries() iteration used for completions and
* required-field checks.
*/
const ALLOW_UNKNOWN_FIELDS = Symbol("allowUnknownFields");
/**
* Mark a `YamlFieldSchemaMap` so that the linter does not warn about unknown
* keys at this mapping level. Use it for schemas where the full set of valid
* keys is not statically known (e.g. device triggers/conditions/actions that
* accept integration-specific fields). Returns the same object for convenience.
*/
export function allowUnknownFields(
map: YamlFieldSchemaMap
): YamlFieldSchemaMap {
(map as Record<symbol, boolean>)[ALLOW_UNKNOWN_FIELDS] = true;
return map;
}
/** Return true if the map was marked via `allowUnknownFields()`. */
export function hasAllowUnknownFields(map: YamlFieldSchemaMap): boolean {
return (map as Record<symbol, boolean>)[ALLOW_UNKNOWN_FIELDS] === true;
}
+925
View File
@@ -0,0 +1,925 @@
/**
* CodeMirror completion source and hover tooltip source for field-aware YAML
* editing in the automation/script/card YAML editors.
*
* Given a `YamlFieldSchemaMap` describing the valid keys (and their selectors,
* descriptions, etc.), this module provides:
*
* - `haYamlCompletionSource` key completions at the current indent level
* plus value completions driven by the selector.
* - `haYamlHoverSource` a `hoverTooltip` callback that shows field
* description, required status and example on hover.
*
* The module is intentionally free of Lit/HA runtime imports so it can be
* consumed in the same lazy code-split chunk as ha-code-editor.
*/
import type {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import { syntaxTree } from "@codemirror/language";
import type { EditorView, Tooltip } from "@codemirror/view";
import type { SyntaxNode } from "@lezer/common";
import { NodeProp } from "@lezer/common";
import type { HassEntities } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import type { AreaRegistryEntry } from "../data/area/area_registry";
import type { DeviceRegistryEntry } from "../data/device/device_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import type { SelectSelector } from "../data/selector";
import type { LocalizeFunc } from "../common/translations/localize";
import {
buildAreaCompletions,
buildDeviceCompletions,
buildEntityCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "./ha_completion_items";
import {
buildArgTooltipDom,
type HassArgHoverContext,
type JinjaArgType,
} from "./jinja_ha_completions";
import "../components/ha-code-editor-jinja-arg-hover";
import "../components/ha-code-editor-yaml-hover";
import type { YamlFieldSchema, YamlFieldSchemaMap } from "./yaml_field_schema";
import { hasAllowUnknownFields } from "./yaml_field_schema";
// ---------------------------------------------------------------------------
// Helpers YAML syntax tree traversal
// ---------------------------------------------------------------------------
/**
* Returns the text content of a syntax node.
*/
function nodeText(node: SyntaxNode, doc: string): string {
return doc.slice(node.from, node.to);
}
/**
* Resolve the field schema for a given key path through a nested schema map.
* Returns `undefined` if the path is not found.
*/
function resolveFieldSchema(
schema: YamlFieldSchemaMap,
path: string[]
): YamlFieldSchema | undefined {
if (path.length === 0) return undefined;
const [head, ...rest] = path;
const field = schema[head];
if (!field) return undefined;
if (rest.length === 0) return field;
if (field.fields) return resolveFieldSchema(field.fields, rest);
return undefined;
}
// ---------------------------------------------------------------------------
// Value completions driven by selector type
// ---------------------------------------------------------------------------
/** validFor pattern for value completions — matches any typed text. */
const VALUE_VALID_FOR = /^.*$/;
const BOOLEAN_COMPLETIONS: Completion[] = [
{ label: "true", type: "keyword" },
{ label: "false", type: "keyword" },
];
/** "Required" marker shown as the `detail` of a required key completion. */
function requiredLabel(ctx: HaYamlCompletionContext): string | undefined {
return ctx.localize?.("ui.components.yaml-editor.schema.required");
}
/**
* A field's description, translated when it is a translation key. Descriptions
* coming from the backend (service/trigger/condition descriptions) are already
* translated plain text and pass through unchanged.
*/
function describe(
field: YamlFieldSchema,
ctx: { localize?: LocalizeFunc }
): string | undefined {
if (!field.description) return undefined;
return (
ctx.localize?.(field.description as Parameters<LocalizeFunc>[0]) ||
field.description
);
}
// Registry-derived completion lists are rebuilt for every completion request,
// and there can be thousands of entities — memoize on the registry identity so
// typing doesn't remap the whole registry on each keystroke.
const memoEntityCompletions = memoizeOne(buildEntityCompletions);
const memoDeviceCompletions = memoizeOne(buildDeviceCompletions);
const memoAreaCompletions = memoizeOne(buildAreaCompletions);
const memoFloorCompletions = memoizeOne(buildFloorCompletions);
const memoLabelCompletions = memoizeOne(buildLabelCompletions);
function valueCompletionsForSelector(
field: YamlFieldSchema,
ctx: HaYamlCompletionContext
): Completion[] | null {
const { selector } = field;
if (!selector) return null;
const type = Object.keys(selector)[0];
if (type === "boolean") {
return BOOLEAN_COMPLETIONS;
}
if (type === "select") {
const opts = (selector as SelectSelector).select?.options;
if (Array.isArray(opts)) {
return opts.map((option) =>
typeof option === "object"
? { label: option.value, type: "enum", detail: option.label }
: { label: option, type: "enum" }
);
}
}
if (type === "entity" && ctx.states) {
return memoEntityCompletions(ctx.states);
}
if (type === "device" && ctx.devices) {
return memoDeviceCompletions(ctx.devices);
}
if (type === "area" && ctx.areas) {
return memoAreaCompletions(ctx.areas);
}
if (type === "floor" && ctx.floors) {
return memoFloorCompletions(ctx.floors);
}
if (type === "label" && ctx.labels) {
return memoLabelCompletions(ctx.labels);
}
if (type === "template") {
return ctx.localize
? [
{
label: "{{ }}",
type: "text",
detail: ctx.localize(
"ui.components.yaml-editor.schema.jinja_template"
),
},
{
label: "{% %}",
type: "text",
detail: ctx.localize(
"ui.components.yaml-editor.schema.jinja_block"
),
},
]
: [
{ label: "{{ }}", type: "text" },
{ label: "{% %}", type: "text" },
];
}
return null;
}
// ---------------------------------------------------------------------------
// Completion source
// ---------------------------------------------------------------------------
export interface HaYamlCompletionContext {
/** The current field schema map, or undefined when no schema is active. */
schema?: YamlFieldSchemaMap;
/** Localize callback for the completion chrome (badges, type hints). */
localize?: LocalizeFunc;
/** Optional entity states for EntitySelector completions. */
states?: HassEntities;
/** Optional device registry for DeviceSelector completions. */
devices?: Record<string, DeviceRegistryEntry>;
/** Optional area registry for AreaSelector completions. */
areas?: Record<string, AreaRegistryEntry>;
/** Optional floor registry for FloorSelector completions. */
floors?: Record<string, FloorRegistryEntry>;
/** Optional label registry for LabelSelector completions. */
labels?: LabelRegistryEntry[];
}
/**
* Returns a CodeMirror `CompletionSource` for field-aware YAML completions.
*
* `getContext` is called on every completion request rather than captured once,
* so a schema or registry that changes after the editor was created is picked
* up without recreating the editor. Register the result once per editor via
* `autocompletion({ override: [..., haYamlCompletionSource(getContext)] })`.
*/
export function haYamlCompletionSource(
getContext: () => HaYamlCompletionContext
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext): CompletionResult | null => {
const ctx = getContext();
const rootSchema = ctx.schema;
if (!rootSchema) return null;
const { state, pos } = context;
const doc = state.doc.toString();
const tree = syntaxTree(state);
const node = tree.resolveInner(pos, -1);
// ---- SEQUENCE ITEM position: cursor is inside a list item ---------------
// Lezer YAML: Pair → BlockSequence → Item → Literal
if (
node.name === "Literal" &&
node.parent?.name === "Item" &&
node.parent?.parent?.name === "BlockSequence"
) {
const seqNode = node.parent.parent; // BlockSequence
const pairNode = seqNode.parent; // Pair
if (pairNode?.name === "Pair") {
const keyNode = pairNode.getChild("Key");
const keyLit = keyNode?.firstChild ?? keyNode;
if (keyLit) {
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pairNode.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
return {
options: completions,
from: node.from,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
// ---- EMPTY SEQUENCE ITEM: cursor after "- " with no Literal yet ----------
if (node.name === "-" && node.parent?.name === "BlockSequence") {
// Only fire when cursor is strictly past the dash (space has been typed).
if (pos <= node.to) return null;
const seqNode = node.parent;
const pairNode = seqNode.parent;
if (pairNode?.name === "Pair") {
const keyNode = pairNode.getChild("Key");
const keyLit = keyNode?.firstChild ?? keyNode;
if (keyLit) {
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pairNode.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
return {
options: completions,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
// ---- VALUE position: cursor is in a Literal value of a Pair --------------
// Lezer YAML: Pair → Key, ":", Literal(value)
if (node.name === "Literal" && node.parent?.name === "Pair") {
const pair = node.parent;
const keyNode = pair.getChild("Key");
if (keyNode) {
const keyLit = keyNode.firstChild ?? keyNode;
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pair.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
// If this field has sub-fields (nested mapping), the Literal is
// actually the first key being typed — offer key completions from
// the sub-schema rather than value completions.
if (field.fields && Object.keys(field.fields).length > 0) {
const word = context.matchBefore(/[\w_-]*/);
const fromPos = word ? word.from : pos;
const completions: Completion[] = Object.entries(field.fields).map(
([key, subField]) => ({
label: key,
type: "yaml-key",
detail: subField.required ? requiredLabel(ctx) : undefined,
info: describe(subField, ctx),
apply: buildKeyApply(key, subField),
boost: subField.required ? 10 : 0,
})
);
if (completions.length === 0) return null;
return {
options: completions,
from: fromPos,
validFor: /^[\w_-]*$/,
};
}
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
const valueLiteral =
pair
.getChildren("Literal")
.find((n) => n !== keyNode.firstChild && n !== keyNode) ?? null;
const from = valueLiteral ? valueLiteral.from : pos;
return { options: completions, from, validFor: VALUE_VALID_FOR };
}
}
}
return null;
}
// ---- EMPTY VALUE: cursor is right after "key: " with no value yet --------
// When there's no value Literal, lezer puts the cursor on the ":" node
// inside the Pair, or on the Pair itself just after the colon.
if (node.name === ":" && node.parent?.name === "Pair") {
const pair = node.parent;
// Only fire when cursor is strictly past the colon (i.e. at least one
// space has been typed), so we don't insert right after "key:".
if (pos > node.to) {
const keyNode = pair.getChild("Key");
if (keyNode) {
const keyLit = keyNode.firstChild ?? keyNode;
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pair.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
// Nested mapping field — offer key completions from sub-schema.
if (field.fields && Object.keys(field.fields).length > 0) {
const completions: Completion[] = Object.entries(
field.fields
).map(([key, subField]) => ({
label: key,
type: "yaml-key",
detail: subField.required ? requiredLabel(ctx) : undefined,
info: describe(subField, ctx),
apply: buildKeyApply(key, subField),
boost: subField.required ? 10 : 0,
}));
if (completions.length > 0) {
return { options: completions, from: pos };
}
}
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
return {
options: completions,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
// ---- KEY position: cursor is on a Key Literal or start of a new Pair ----
// Determine which schema level to offer completions at.
let schemaLevel: YamlFieldSchemaMap = rootSchema;
// Find the BlockMapping we are inside.
let keyLiteralNode: SyntaxNode | null = null;
// Are we inside a Key node?
if (node.name === "Literal" && node.parent?.name === "Key") {
keyLiteralNode = node;
} else if (node.name === "Key") {
// cursor is directly on a Key node, no literal node to pin
}
// Guard: walk up from the cursor node. If we pass through a node that is
// a non-BlockMapping value child of a Pair (scalar Literal, FlowSequence,
// FlowMapping, or a "," / "[" / "]" inside a flow node), the cursor is in
// a value position — do not offer key completions.
{
let n: SyntaxNode | null = keyLiteralNode ?? node;
while (n) {
const p = n.parent;
if (p?.name === "Pair") {
// n is a direct child of a Pair. If it is NOT a Key, it is value-side.
if (n.name !== "Key" && n.name !== ":") {
// BlockMapping / BlockSequence as value means nested keys — OK.
if (n.name !== "BlockMapping" && n.name !== "BlockSequence") {
return null;
}
}
}
// Inside any flow node (FlowSequence, FlowMapping) → value position.
if (
n.name === "FlowSequence" ||
n.name === "FlowMapping" ||
n.name === "," ||
n.name === "[" ||
n.name === "]" ||
n.name === "{" ||
n.name === "}"
) {
return null;
}
n = p;
}
}
// Also guard: cursor is past end of an inner BlockMapping (e.g. "brightness: |"
// where the inner BM ends at the colon). Find the deepest Pair whose ":"
// is on the cursor line and which has no block value — that means cursor is
// in a scalar value gap.
// Also handles: empty sequence item "- |" where cursor is past the BlockSequence
// end — find the "-" on the cursor line by text-scanning, then locate it in
// the syntax tree to determine the field and return value completions.
{
const curLine = state.doc.lineAt(pos);
const lineText = state.doc.sliceString(curLine.from, curLine.to);
// Only proceed if the line looks like a sequence item (optional spaces + "- ")
const dashMatch = /^(\s*)-(\s*)$/.exec(lineText);
if (dashMatch && pos > curLine.from + dashMatch[1].length) {
// Find the "-" node in the tree by resolving at its text position.
const dashPos = curLine.from + dashMatch[1].length;
const dashNode = syntaxTree(state).resolveInner(dashPos, 1);
// Walk up to find the BlockSequence → Pair → field schema.
let n: SyntaxNode | null = dashNode;
while (n) {
if (n.name === "BlockSequence") {
const pairNode2 = n.parent;
if (pairNode2?.name === "Pair") {
const keyNode2 = pairNode2.getChild("Key");
const keyLit2 = keyNode2?.firstChild ?? keyNode2;
if (keyLit2) {
const keyText2 = nodeText(keyLit2, doc);
const ancestorPath2 = getAncestorKeyPath(pairNode2.parent, doc);
const field2 = resolveFieldSchema(rootSchema, [
...ancestorPath2,
keyText2,
]);
if (field2) {
const completions = valueCompletionsForSelector(field2, ctx);
if (completions) {
return {
options: completions,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
n = n.parent;
}
}
}
// ---- SCALAR VALUE GAP: cursor is after "key: " but lezer has no Literal ----
// Resolve from the start of the cursor line to find the Pair whose key
// is on this line. Walking up from `pos` can land inside a sibling node's
// BlockSequence when the previous field has list items, causing us to
// miss the current Pair entirely.
{
const curLine2 = state.doc.lineAt(pos);
const lineText2 = state.doc.sliceString(curLine2.from, curLine2.to);
// Only proceed if line looks like " key: " (optional spaces, a key, colon, optional space)
// and NOT a sequence item ("- ").
const keyColonMatch = /^(\s*)[\w_-]+\s*:\s*$/.test(lineText2);
if (keyColonMatch) {
// Resolve a node at the line start to find the Pair for this key.
const lineStartNode = syntaxTree(state).resolveInner(
curLine2.from + lineText2.search(/\S/),
1
);
let n: SyntaxNode | null = lineStartNode;
while (n) {
if (n.name === "Pair") {
const colon = n.getChild(":");
if (
colon &&
state.doc.lineAt(colon.from).number === curLine2.number &&
pos > colon.to
) {
const hasBlockValue =
n.getChild("BlockMapping") !== null ||
n.getChild("BlockSequence") !== null ||
n.getChild("FlowSequence") !== null ||
n.getChild("FlowMapping") !== null;
const hasScalarValue = n.getChildren("Literal").length > 1;
if (!hasBlockValue && !hasScalarValue) {
// Cursor is in scalar value gap (e.g. "area_id: |").
const keyNode2 = n.getChild("Key");
const keyLit2 = keyNode2?.firstChild ?? keyNode2;
if (keyLit2) {
const keyText2 = nodeText(keyLit2, doc);
const ancestorPath2 = getAncestorKeyPath(n.parent, doc);
const field2 = resolveFieldSchema(rootSchema, [
...ancestorPath2,
keyText2,
]);
if (field2) {
const completions2 = valueCompletionsForSelector(
field2,
ctx
);
if (completions2) {
return {
options: completions2,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
return null;
}
}
break; // found the Pair for this line, stop searching
}
n = n.parent;
}
}
}
// Walk up to find which BlockMapping this key belongs to.
// When the cursor is on an empty/blank line, resolveInner returns a parent
// BlockMapping rather than the inner one we're actually inside.
// Strategy: find the indentation of the cursor line, then look backwards
// for the nearest non-empty line at a *greater* indent — that line's first
// char resolves into the inner BlockMapping we want.
let bmNode: SyntaxNode | null = null;
let pairNode: SyntaxNode | null = null;
{
const curLine = state.doc.lineAt(pos);
const lineText = state.doc.sliceString(curLine.from, curLine.to);
const curIndent = lineText.search(/\S/);
let resolvePos: number;
if (curIndent >= 0) {
// Line has content — resolve from its first non-space char.
resolvePos = curLine.from + curIndent;
} else {
// Empty/blank line — scan backwards for a line with greater indent
// (i.e. a sibling or child line that's already inside the same block).
resolvePos = pos; // fallback
for (let ln = curLine.number - 1; ln >= 1; ln--) {
const prevLine = state.doc.line(ln);
const prevText = state.doc.sliceString(prevLine.from, prevLine.to);
const prevIndent = prevText.search(/\S/);
if (prevIndent < 0) continue; // skip blank lines
// A line with more indentation is inside the same or deeper block.
if (prevIndent > 0) {
resolvePos = prevLine.from + prevIndent;
break;
}
// Hit a root-level line — we're at root level.
break;
}
}
const resolveNode = syntaxTree(state).resolveInner(resolvePos, -1);
let n: SyntaxNode | null = keyLiteralNode ?? resolveNode;
while (n) {
if (n.name === "Pair") pairNode = n;
if (n.name === "BlockMapping") {
bmNode = n;
break;
}
n = n.parent;
}
}
if (bmNode) {
// Build the path of ancestor keys above this BlockMapping.
const ancestorPath = getAncestorKeyPath(bmNode, doc);
if (ancestorPath.length > 0) {
const parentField = resolveFieldSchema(rootSchema, ancestorPath);
schemaLevel = parentField?.fields ?? {};
}
}
if (Object.keys(schemaLevel).length === 0) return null;
// Determine what has already been typed for this key.
const word = context.matchBefore(/[\w_-]*/);
if (!word && !context.explicit) return null;
const fromPos = word ? word.from : pos;
// Exclude keys already present in the current mapping.
const alreadyUsed = new Set<string>();
if (bmNode) {
let c = bmNode.firstChild;
while (c) {
if (c.name === "Pair" && c !== pairNode) {
const k = c.getChild("Key");
const lit = k?.firstChild ?? k;
if (lit) alreadyUsed.add(nodeText(lit, doc));
}
c = c.nextSibling;
}
}
const completions: Completion[] = Object.entries(schemaLevel)
.filter(([key]) => !alreadyUsed.has(key))
.map(([key, field]) => ({
label: key,
type: "yaml-key",
detail: field.required ? requiredLabel(ctx) : undefined,
info: describe(field, ctx),
// Insert "key: " or "key:\n " depending on selector type
apply: buildKeyApply(key, field),
boost: field.required ? 10 : 0,
}));
if (completions.length === 0) return null;
return { options: completions, from: fromPos, validFor: /^[\w_-]*$/ };
};
}
/**
* Build the text to insert when a key completion is accepted.
* For object/sequence selectors we add a newline; for simple values "key: ".
*/
function buildKeyApply(key: string, field: YamlFieldSchema): string {
const type = field.selector ? Object.keys(field.selector)[0] : null;
if (type === "object" || type === "action" || type === "condition") {
return `${key}:\n `;
}
if (field.fields && Object.keys(field.fields).length > 0) {
return `${key}:\n `;
}
return `${key}: `;
}
/**
* Walk up from a node (expected to be a BlockMapping) and collect the
* sequence of Pair keys that enclose it.
*/
function getAncestorKeyPath(bm: SyntaxNode | null, doc: string): string[] {
const path: string[] = [];
let cur: SyntaxNode | null = bm;
while (cur) {
if (cur.name === "Pair") {
const keyNode = cur.getChild("Key");
const lit = keyNode?.firstChild ?? keyNode;
if (lit) path.unshift(nodeText(lit, doc));
}
cur = cur.parent;
}
return path.filter(Boolean);
}
// ---------------------------------------------------------------------------
// Hover tooltip source
// ---------------------------------------------------------------------------
/**
* Selector types whose values are HA IDs, mapped to the Jinja arg type that
* knows how to render a rich tooltip for them.
*/
const SELECTOR_ARG_TYPES: Record<string, JinjaArgType | undefined> = {
entity: "entity_id",
device: "device_id",
area: "area_id",
floor: "floor_id",
label: "label_id",
};
export interface HaYamlHoverContext {
/** The current field schema map. */
schema: YamlFieldSchemaMap;
/**
* Optional localize callback used to translate field descriptions that are
* i18n keys, and the tooltip's own labels. When absent, the raw string is
* displayed and the labels fall back to English.
*/
localize?: LocalizeFunc;
/** Optional HA context for rich entity/device/area value tooltips. */
hassContext?: HassArgHoverContext;
}
/**
* Returns a `hoverTooltip` callback. Register it via:
*
* hoverTooltip((view, pos) => haYamlHoverSource(view, pos, ctx))
*/
export function haYamlHoverSource(
view: EditorView,
pos: number,
ctx: HaYamlHoverContext
): Tooltip | null {
const doc = view.state.doc.toString();
const tree = syntaxTree(view.state);
const node = tree.resolveInner(pos, -1);
// ---- Value hover: entity/device/area Literal in a Pair value or list item --
if (ctx.hassContext && node.name === "Literal") {
// Resolve the Pair that owns this value — either directly (scalar value)
// or via BlockSequence → Item (list item).
let pair: SyntaxNode | null = null;
if (node.parent?.name === "Pair") {
// scalar value: Literal is a direct child of Pair (not the Key)
const keyNode = node.parent.getChild("Key");
const keyLit2 = keyNode?.firstChild ?? keyNode;
if (keyLit2 && node !== keyLit2 && node.from !== keyLit2.from) {
pair = node.parent;
}
} else if (
node.parent?.name === "Item" &&
node.parent.parent?.name === "BlockSequence" &&
node.parent.parent.parent?.name === "Pair"
) {
// list item: Literal → Item → BlockSequence → Pair
pair = node.parent.parent.parent;
}
if (pair) {
const keyNode = pair.getChild("Key");
const keyLit2 = keyNode?.firstChild ?? keyNode;
if (keyLit2) {
const keyText2 = nodeText(keyLit2, doc);
const ancestorPath2 = getAncestorKeyPath(pair.parent, doc);
const field2 = resolveFieldSchema(ctx.schema, [
...ancestorPath2,
keyText2,
]);
if (field2?.selector) {
const selectorType = Object.keys(field2.selector)[0];
const argType = SELECTOR_ARG_TYPES[selectorType];
if (argType) {
const value = nodeText(node, doc);
const dom = buildArgTooltipDom(argType, value, ctx.hassContext);
if (dom) {
return {
pos: node.from,
end: node.to,
above: true,
create: () => ({ dom }),
};
}
}
}
}
}
}
// ---- Key hover: show field name, description, type, example, default ----
let keyLit: SyntaxNode | null = null;
if (node.name === "Literal" && node.parent?.name === "Key") {
keyLit = node;
} else if (node.name === "Key") {
keyLit = node.firstChild;
}
if (!keyLit) return null;
const keyText = nodeText(keyLit, doc);
if (!keyText) return null;
// Build the path from ancestor BlockMappings.
const pairNode = keyLit.parent?.parent; // Literal → Key → Pair
const bmNode = pairNode?.parent; // Pair → BlockMapping
const ancestorPath = getAncestorKeyPath(bmNode ?? null, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(ctx.schema, fullPath);
if (!field) return null;
return {
pos: keyLit.from,
end: keyLit.to,
above: true,
create() {
const dom = document.createElement("ha-code-editor-yaml-hover");
dom.fieldName = keyText;
dom.fieldSchema = field;
dom.localize = ctx.localize;
return { dom };
},
};
}
// ---------------------------------------------------------------------------
// Linting
// ---------------------------------------------------------------------------
export interface Diagnostic {
from: number;
to: number;
severity: "error" | "warning" | "info";
message: string;
}
/**
* Produces lint diagnostics for a YAML document given a field schema.
*
* Checks, at every mapping level the schema describes:
* - Keys the schema doesn't know (warning), unless the level was marked with
* `allowUnknownFields()`
* - Required keys that are missing (error)
*/
export function haYamlLintSource(
view: EditorView,
schema: YamlFieldSchemaMap,
localize?: LocalizeFunc
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const doc = view.state.doc.toString();
function lintMapping(
bmNode: SyntaxNode,
schemaLevel: YamlFieldSchemaMap
): void {
const presentKeys = new Set<string>();
let child = bmNode.firstChild;
while (child) {
if (child.name === "Pair") {
const keyNode = child.getChild("Key");
const lit = keyNode?.firstChild ?? keyNode;
if (lit) {
const key = nodeText(lit, doc);
presentKeys.add(key);
if (!(key in schemaLevel)) {
if (!hasAllowUnknownFields(schemaLevel)) {
diagnostics.push({
from: lit.from,
to: lit.to,
severity: "warning",
message:
localize?.("ui.components.yaml-editor.schema.unknown_field", {
field: key,
}) || `Unknown field: "${key}"`,
});
}
} else {
// Recurse into nested mappings.
const fieldDef = schemaLevel[key];
if (fieldDef.fields) {
const valueNode = child.getChildren("BlockMapping").find(Boolean);
if (valueNode) lintMapping(valueNode, fieldDef.fields);
}
}
}
}
child = child.nextSibling;
}
// Report required fields that are missing from this mapping. Anchored to
// the start of the mapping, since there is no key to point at.
for (const [key, fieldDef] of Object.entries(schemaLevel)) {
if (fieldDef.required && !presentKeys.has(key)) {
const from = bmNode.parent?.from ?? 0;
diagnostics.push({
from,
to: from + 1,
severity: "error",
message:
localize?.("ui.components.yaml-editor.schema.missing_field", {
field: key,
}) || `Required field missing: "${key}"`,
});
}
}
}
// Find the root BlockMapping.
// With jinja({ base: yaml() }) the outer tree is Template → Text, and the
// YAML parse is mounted on the Text node via NodeProp.mounted. With plain
// yaml() the tree is Stream → Document → BlockMapping directly.
let bm: SyntaxNode | null = null;
const outerTree = syntaxTree(view.state);
// Try plain yaml() path first: walk down until BlockMapping.
let cur: SyntaxNode | null = outerTree.topNode;
while (cur && cur.name !== "BlockMapping") {
// If this node has a mounted subtree (jinja wrapper), use that tree instead.
const mounted = cur.node?.tree
? cur.node.tree.prop(NodeProp.mounted)
: null;
if (mounted) {
// The mounted tree root — walk down into it.
cur = mounted.tree.topNode;
continue;
}
cur = cur.firstChild;
}
if (cur?.name === "BlockMapping") {
bm = cur;
}
if (bm) {
lintMapping(bm, schema);
}
return diagnostics;
}
+185 -17
View File
@@ -702,9 +702,9 @@
"geo_location": "[%key:ui::panel::config::automation::editor::triggers::type::geo_location::label%]",
"homeassistant": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::label%]",
"mqtt": "[%key:ui::panel::config::automation::editor::triggers::type::mqtt::label%]",
"numeric_state": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::label%]",
"numeric_state": "[%key:ui::panel::config::automation::editor::conditions::type::numeric_state::label%]",
"persistent_notification": "[%key:ui::panel::config::automation::editor::triggers::type::persistent_notification::label%]",
"state": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"sun": "[%key:ui::panel::config::automation::editor::triggers::type::sun::label%]",
"tag": "[%key:ui::panel::config::automation::editor::triggers::type::tag::label%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::template::label%]",
@@ -1435,7 +1435,16 @@
"exit_fullscreen": "Exit fullscreen",
"find_and_replace": "Find and replace",
"test_on": "Turn on testing",
"test_off": "Turn off testing"
"test_off": "Turn off testing",
"schema": {
"required": "Required",
"example": "Example:",
"default": "Default:",
"unknown_field": "Unknown field: \"{field}\"",
"missing_field": "Required field missing: \"{field}\"",
"jinja_template": "Jinja2 template",
"jinja_block": "Jinja2 block"
}
},
"state-content-picker": {
"state": "State",
@@ -1667,6 +1676,11 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"floor": "Floor",
"entity_id": "Entity ID",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
"raw": "Raw",
@@ -2073,6 +2087,8 @@
"error_backup_state": "An error occurred while getting the current backup state. Error: {error}",
"wait_for_upload": "Wait for backup upload to finish",
"wait_for_restore": "Wait for backup restore to finish",
"interrupt_automations": "The following automations and scripts are currently running and will be interrupted:",
"no_interrupt_automations": "No automations or scripts are currently running.",
"reload": {
"title": "Quick reload",
"description": "Loads new YAML configurations without a restart.",
@@ -2083,14 +2099,14 @@
"title": "Restart Home Assistant",
"description": "Interrupts all running automations and scripts.",
"confirm_title": "Restart Home Assistant?",
"confirm_description": "This will interrupt all running automations and scripts.",
"confirm_description": "All integrations will be reloaded.",
"confirm_action": "Restart",
"confirm_action_backup": "Wait and restart",
"failed": "Failed to restart Home Assistant"
},
"stop": {
"confirm_title": "Stop Home Assistant?",
"confirm_description": "This will interrupt all running automations and scripts.",
"confirm_description": "Home Assistant will be stopped.",
"confirm_action": "Stop"
},
"reboot": {
@@ -2769,7 +2785,6 @@
"caption": "Updates",
"description": "Manage updates of Home Assistant, apps, and devices",
"group_system": "Home Assistant",
"group_integrations": "Integrations",
"group_apps": "Apps",
"update_all": "Update all",
"no_updates": "No updates available",
@@ -5348,7 +5363,7 @@
}
},
"state": {
"label": "State",
"label": "State changed",
"attribute": "Attribute (optional)",
"from": "From (optional)",
"for": "For",
@@ -5356,7 +5371,8 @@
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
},
"for_type": {
"choices": {
@@ -5386,7 +5402,7 @@
}
},
"numeric_state": {
"label": "Numeric state",
"label": "Numeric state crossed threshold",
"above": "Above",
"below": "Below",
"lower_limit": "Lower limit",
@@ -5398,7 +5414,10 @@
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
},
"threshold_type": {
"choices": {
@@ -5642,7 +5661,7 @@
"numeric_state": {
"type_value": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_value%]",
"type_input": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_input%]",
"label": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::label%]",
"label": "Numeric state",
"above": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::above%]",
"below": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::below%]",
"lower_limit": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::lower_limit%]",
@@ -5652,7 +5671,10 @@
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}"
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
}
},
"or": {
@@ -5664,12 +5686,13 @@
}
},
"state": {
"label": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"state": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"label": "State",
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"description": {
"picker": "Tests if an entity (or attribute) is in a specific state.",
"no_entity": "If state confirmed",
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }"
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
}
},
"sun": {
@@ -6027,7 +6050,150 @@
},
"paste_toast_message": "Pasted automation from clipboard",
"paste_invalid_yaml": "Pasted value is not valid YAML",
"paste_invalid_config": "Pasted automation is not editable in the visual editor"
"paste_invalid_config": "Pasted automation is not editable in the visual editor",
"yaml_schema": {
"trigger_base": {
"trigger": "The trigger type (platform).",
"id": "An optional ID for the trigger, used to identify it in conditions or templates.",
"alias": "A friendly name for this trigger.",
"enabled": "Whether this trigger is enabled. Defaults to true.",
"variables": "Variables to set when this trigger fires."
},
"condition_base": {
"condition": "The condition type.",
"alias": "A friendly name for this condition.",
"enabled": "Whether this condition is enabled. Defaults to true."
},
"action_base": {
"alias": "A friendly name for this action.",
"enabled": "Whether this action is enabled. Defaults to true.",
"continue_on_error": "If true, the automation continues even if this action fails."
},
"actions": {
"delay": {
"delay": "Duration to wait. Can be a number (seconds), a time string (HH:MM:SS), or a mapping with hours/minutes/seconds/milliseconds."
},
"wait_template": {
"wait_template": "A Jinja2 template that must evaluate to true before continuing.",
"timeout": "Maximum time to wait. After this the action continues (or stops if continue_on_timeout is false).",
"continue_on_timeout": "Whether to continue when the timeout is reached. Defaults to true."
},
"wait_for_trigger": {
"wait_for_trigger": "One or more triggers to wait for before continuing.",
"timeout": "Maximum time to wait.",
"continue_on_timeout": "[%key:ui::panel::config::automation::editor::yaml_schema::actions::wait_template::continue_on_timeout%]"
},
"event": {
"event": "The event type to fire.",
"event_data": "Data to include with the event.",
"event_data_template": "Templated data to include with the event."
},
"condition": {
"condition": "The condition type to check. The automation stops if the condition is false."
},
"stop": {
"stop": "Message to log when stopping the automation.",
"error": "If true, this is logged as an error. Defaults to false.",
"response_variable": "Variable name to store when stopping and returning a response."
},
"repeat": {
"repeat": "Repeat configuration — use count, while, until, or for_each.",
"count": "Number of times to repeat.",
"while": "Repeat while these conditions are true.",
"until": "Repeat until these conditions are true.",
"for_each": "List of items to iterate over.",
"sequence": "Actions to perform on each iteration."
},
"choose": {
"choose": "List of options; the first matching one is executed.",
"conditions": "Conditions that must be met for this option to run.",
"sequence": "Actions to run if the conditions match.",
"alias": "A friendly name for this option.",
"default": "Actions to run when no option matched."
},
"if": {
"if": "Conditions to check.",
"then": "Actions to run when the condition is true.",
"else": "Actions to run when the condition is false."
},
"sequence": {
"sequence": "A list of actions to run in order."
},
"parallel": {
"parallel": "A list of actions (or scripts) to run in parallel."
},
"variables": {
"variables": "Key/value pairs to set as variables in the automation context."
},
"set_conversation_response": {
"set_conversation_response": "The text response to return to the conversation agent."
},
"service": {
"data": "Service call data (field values).",
"response_variable": "Variable name to store the action response in.",
"action": "The action to call."
}
},
"conditions": {
"zone": {
"zone": "The zone the entity must be in.",
"entity_id": "The person or device_tracker entity to check."
},
"and": {
"conditions": "All of these conditions must be true."
},
"or": {
"conditions": "At least one of these conditions must be true."
},
"not": {
"conditions": "None of these conditions must be true."
},
"options": "Condition options."
},
"triggers": {
"event": {
"event_type": "The event type to listen for.",
"event_data": "Optional event data to match.",
"context": "Optional context to match (e.g. user_id)."
},
"zone": {
"entity_id": "The person or device_tracker entity to watch.",
"zone": "The zone to watch.",
"event": "Whether to trigger on zone entry or exit."
},
"tag": {
"tag_id": "The NFC/QR tag ID(s) to watch."
},
"webhook": {
"webhook_id": "The webhook ID. Will be part of the webhook URL.",
"allowed_methods": "HTTP methods that are accepted (default: POST, PUT).",
"local_only": "Only allow requests from the local network. Defaults to true."
},
"conversation": {
"command": "The voice command phrase(s) to match."
},
"options": "Trigger options."
},
"target": {
"target": "The target entities, devices, areas, floors, or labels.",
"entity_id": "One or more entity IDs to target.",
"device_id": "One or more device IDs to target.",
"area_id": "One or more area IDs to target.",
"floor_id": "One or more floor IDs to target.",
"label_id": "One or more label IDs to target."
},
"numeric_threshold": {
"active_choice": "Whether to use a fixed number or an entity state.",
"number": "Fixed numeric value.",
"entity": "Entity whose state provides the value.",
"unit_of_measurement": "Unit of measurement.",
"value_entry": "Threshold value entry.",
"type": "Comparison type.",
"value": "Threshold value (for above / below types).",
"value_min": "Lower bound (for between / outside types).",
"value_max": "Upper bound (for between / outside types)."
}
}
},
"trace": {
"refresh": "[%key:ui::common::refresh%]",
@@ -6786,7 +6952,8 @@
"disabled_by": {
"user": "user",
"integration": "integration",
"config_entry": "config entry"
"config_entry": "config entry",
"device": "parent device"
},
"enabled_description": "Disabled devices and services will not be shown and entities belonging to them will be disabled, too.",
"open_configuration_url": "Visit",
@@ -11483,6 +11650,7 @@
"analytics": {
"header": "Help us help you",
"finish": "Next",
"waiting": "Waiting for Home Assistant to finish starting up…",
"preferences": {
"base": {
"title": "[%key:ui::panel::config::analytics::preferences::base::title%]",
@@ -77,6 +77,7 @@ export const mockDevice = (
disabled_by: null,
configuration_url: null,
primary_config_entry: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
...partial,
@@ -1,43 +1,65 @@
import { describe, expect, it } from "vitest";
import { assert, describe, it } from "vitest";
import { getDeviceArea } from "../../../../src/common/entity/context/get_device_context";
import { mockArea, mockDevice } from "./context-mock";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { HomeAssistant } from "../../../../src/types";
const area = (id: string): AreaRegistryEntry =>
({ area_id: id, name: id }) as AreaRegistryEntry;
const device = (
partial: Partial<DeviceRegistryEntry> & { id: string }
): DeviceRegistryEntry =>
({
area_id: null,
parent_device_id: null,
...partial,
}) as DeviceRegistryEntry;
const AREAS: HomeAssistant["areas"] = {
kitchen: area("kitchen"),
living_room: area("living_room"),
};
describe("getDeviceArea", () => {
it("returns the device's own area", () => {
const dev = device({ id: "d1", area_id: "kitchen" });
assert.strictEqual(getDeviceArea(dev, AREAS, {})?.area_id, "kitchen");
});
it("returns undefined when the device has no area", () => {
const device = mockDevice({
id: "device_1",
});
const result = getDeviceArea(device, {});
expect(result).toBeUndefined();
const dev = device({ id: "d1" });
assert.strictEqual(getDeviceArea(dev, AREAS, {}), undefined);
});
it("returns the area when the device area exists", () => {
const device = mockDevice({
id: "device_2",
area_id: "area_1",
});
const area = mockArea({
area_id: "area_1",
});
const result = getDeviceArea(device, {
area_1: area,
});
expect(result).toEqual(area);
it("inherits the parent's area for a child without its own area", () => {
const parent = device({ id: "parent", area_id: "living_room" });
const child = device({ id: "child", parent_device_id: "parent" });
const devices = { parent, child };
assert.strictEqual(
getDeviceArea(child, AREAS, devices)?.area_id,
"living_room"
);
});
it("returns undefined when the device area is missing", () => {
const device = mockDevice({
id: "device_3",
area_id: "area_2",
it("prefers the child's own area over the parent's", () => {
const parent = device({ id: "parent", area_id: "living_room" });
const child = device({
id: "child",
area_id: "kitchen",
parent_device_id: "parent",
});
const devices = { parent, child };
assert.strictEqual(
getDeviceArea(child, AREAS, devices)?.area_id,
"kitchen"
);
});
const result = getDeviceArea(device, {});
expect(result).toBeUndefined();
it("returns undefined when the parent also has no area", () => {
const parent = device({ id: "parent" });
const child = device({ id: "child", parent_device_id: "parent" });
const devices = { parent, child };
assert.strictEqual(getDeviceArea(child, AREAS, devices), undefined);
});
});
@@ -93,6 +93,62 @@ describe("getEntityContext", () => {
});
});
it("should inherit the parent device's area for an entity on a child device", () => {
const entity = mockEntity({
entity_id: "switch.outlet_1",
device_id: "child_1",
});
const childDevice = mockDevice({
id: "child_1",
parent_device_id: "parent_1",
});
const parentDevice = mockDevice({
id: "parent_1",
area_id: "area_1",
});
const area = mockArea({
area_id: "area_1",
floor_id: "floor_1",
});
const floor = mockFloor({
floor_id: "floor_1",
});
const stateObj = mockStateObj({
entity_id: "switch.outlet_1",
});
const hass = {
entities: {
"switch.outlet_1": entity,
},
devices: {
child_1: childDevice,
parent_1: parentDevice,
},
areas: {
area_1: area,
},
floors: {
floor_1: floor,
},
} as unknown as HomeAssistant;
const result = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toEqual({
entity,
device: childDevice,
area,
floor,
});
});
it("should return the correct context when the entity has an area but no device", () => {
const entity = mockEntity({
entity_id: "sensor.kitchen",
@@ -0,0 +1,155 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import type { Condition, Trigger } from "../../src/data/automation";
import {
describeCondition,
describeTrigger,
} from "../../src/data/automation_i18n";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import en from "../../src/translations/en.json";
import type { HomeAssistant } from "../../src/types";
type TranslationNode = string | { [key: string]: TranslationNode };
const localize = (key: string, values?: Record<string, unknown>) => {
const message = key
.split(".")
.reduce<TranslationNode | undefined>(
(translations, part) =>
typeof translations === "object" ? translations[part] : undefined,
en as TranslationNode
);
return typeof message === "string"
? (new IntlMessageFormat(message, "en").format(values) as string)
: "";
};
const hass = {
localize,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
config: { time_zone: "Etc/UTC" },
states: {
"light.kitchen": {
entity_id: "light.kitchen",
state: "on",
attributes: { friendly_name: "Kitchen light" },
},
"sensor.temperature": {
entity_id: "sensor.temperature",
state: "21",
attributes: { friendly_name: "Temperature" },
},
},
entities: {},
formatEntityState: (_stateObj, state?: string) => state ?? "",
} as unknown as HomeAssistant;
const describeRowTrigger = (trigger: Trigger) =>
describeTrigger(trigger, hass, [], { hideEntities: true });
const describeRowCondition = (condition: Condition) =>
describeCondition(condition, hass, [], { hideEntities: true });
describe("describing state triggers and conditions", () => {
const trigger: Trigger = {
trigger: "state",
entity_id: "light.kitchen",
to: "on",
};
const condition: Condition = {
condition: "state",
entity_id: "light.kitchen",
state: "on",
};
it("names the entities by default", () => {
expect(describeTrigger(trigger, hass, [])).toBe(
"When Kitchen light changes to on"
);
expect(describeCondition(condition, hass, [])).toBe(
"If Kitchen light is on"
);
});
it("leaves the entities out when they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("State changed to on");
expect(describeRowCondition(condition)).toBe("State is on");
});
it("falls back to the label when nothing is configured yet", () => {
expect(
describeRowTrigger({ trigger: "state", entity_id: "light.kitchen" })
).toBe("State or any attribute changed");
expect(
describeRowCondition({
condition: "state",
entity_id: "light.kitchen",
state: [],
})
).toBe("State");
});
});
describe("describing numeric state triggers and conditions", () => {
const trigger: Trigger = {
trigger: "numeric_state",
entity_id: "sensor.temperature",
above: 20,
};
const condition: Condition = {
condition: "numeric_state",
entity_id: "sensor.temperature",
above: 20,
};
it("names the entities by default", () => {
expect(describeTrigger(trigger, hass, [])).toBe(
"When Temperature is above 20"
);
expect(describeCondition(condition, hass, [])).toBe(
"If Temperature is above 20"
);
});
it("leaves the entities out when they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("Numeric state crossed above 20");
expect(describeRowCondition(condition)).toBe("Numeric state is above 20");
});
it("describes both thresholds", () => {
expect(describeRowTrigger({ ...trigger, below: 30 })).toBe(
"Numeric state crossed above 20 and below 30"
);
expect(describeRowCondition({ ...condition, below: 30 })).toBe(
"Numeric state is above 20 and below 30"
);
});
it("falls back to the label without a threshold", () => {
expect(
describeRowTrigger({
trigger: "numeric_state",
entity_id: "sensor.temperature",
})
).toBe("Numeric state crossed threshold");
expect(
describeRowCondition({
condition: "numeric_state",
entity_id: "sensor.temperature",
})
).toBe("Numeric state");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { assert, describe, it } from "vitest";
import { resolveChildDevices } from "../../src/data/ws-device_registry";
import type {
ChildDeviceRegistryEntry,
DeviceRegistryEntry,
} from "../../src/data/device/device_registry";
const parent: DeviceRegistryEntry = {
id: "parent",
config_entries: ["entry-1"],
config_entries_subentries: { "entry-1": [null] },
connections: [["mac", "aa:bb:cc:dd:ee:ff"]],
identifiers: [["hue", "strip-1"]],
manufacturer: "Acme",
model: "Power Strip",
model_id: "PS-1",
name: "Power strip",
labels: ["strip"],
sw_version: "1.0",
hw_version: "2.0",
serial_number: "SN-1",
via_device_id: "bridge",
area_id: "living_room",
name_by_user: null,
entry_type: null,
disabled_by: null,
configuration_url: "http://strip.local",
primary_config_entry: "entry-1",
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
const child: ChildDeviceRegistryEntry = {
id: "child",
config_entry_id: "entry-1",
config_subentry_id: "sub-1",
identifiers: [["hue", "outlet-1"]],
name: "Outlet 1",
name_by_user: "Coffee machine",
labels: ["outlet"],
area_id: "kitchen",
disabled_by: null,
parent_device_id: "parent",
created_at: 5,
modified_at: 6,
};
describe("resolveChildDevices", () => {
it("leaves full devices untouched", () => {
const [resolved] = resolveChildDevices([parent]);
assert.strictEqual(resolved, parent);
});
it("resolves a child into a complete device entry", () => {
const result = resolveChildDevices([parent, child]);
const resolved = result.find((d) => d.id === "child")!;
// Config-entry association comes from the child's own config entry.
assert.deepEqual(resolved.config_entries, ["entry-1"]);
assert.deepEqual(resolved.config_entries_subentries, {
"entry-1": ["sub-1"],
});
assert.strictEqual(resolved.primary_config_entry, "entry-1");
// Hardware/display fields are inherited from the parent.
assert.strictEqual(resolved.manufacturer, "Acme");
assert.strictEqual(resolved.model, "Power Strip");
assert.strictEqual(resolved.model_id, "PS-1");
assert.strictEqual(resolved.sw_version, "1.0");
assert.strictEqual(resolved.hw_version, "2.0");
assert.strictEqual(resolved.serial_number, "SN-1");
assert.strictEqual(resolved.configuration_url, "http://strip.local");
assert.strictEqual(resolved.entry_type, null);
// Identity fields are NOT inherited — a child is not the parent.
assert.deepEqual(resolved.connections, []);
assert.strictEqual(resolved.via_device_id, null);
// The child's own fields win.
assert.strictEqual(resolved.id, "child");
assert.strictEqual(resolved.name, "Outlet 1");
assert.strictEqual(resolved.name_by_user, "Coffee machine");
assert.strictEqual(resolved.area_id, "kitchen");
assert.deepEqual(resolved.labels, ["outlet"]);
assert.deepEqual(resolved.identifiers, [["hue", "outlet-1"]]);
assert.strictEqual(resolved.parent_device_id, "parent");
assert.strictEqual(resolved.created_at, 5);
assert.strictEqual(resolved.modified_at, 6);
});
it("falls back to null display fields when the parent is missing", () => {
const [resolved] = resolveChildDevices([child]);
assert.deepEqual(resolved.config_entries, ["entry-1"]);
assert.strictEqual(resolved.manufacturer, null);
assert.strictEqual(resolved.model, null);
assert.deepEqual(resolved.connections, []);
assert.strictEqual(resolved.via_device_id, null);
assert.strictEqual(resolved.parent_device_id, "parent");
});
it("preserves ordering of the mixed list", () => {
const result = resolveChildDevices([child, parent]);
assert.deepEqual(
result.map((d) => d.id),
["child", "parent"]
);
});
});
+1
View File
@@ -68,6 +68,7 @@ const subscriptionResults: Record<string, unknown> = {
};
const commandResults: Record<string, unknown> = {
analytics: { preferences: {} },
"analytics/preferences": {},
"auth/current_user": currentUser,
"brands/access_token": { token: "brands-token" },
+2 -2
View File
@@ -77,8 +77,8 @@ export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
// The details view renders the state and attributes grouped lists.
content: [{ selector: "ha-grouped-list" }],
},
];
@@ -0,0 +1,364 @@
import { describe, expect, test } from "vitest";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { Action } from "../../../../src/data/script";
import type { HomeAssistant } from "../../../../src/types";
import {
actionSchemaKey,
actionToYamlSchema,
builtInActionSchema,
builtInConditionSchema,
builtInTriggerSchema,
haFormSchemaToYamlFieldSchemaMap,
serviceActionSchema,
} from "../../../../src/panels/config/automation/yaml_schema_helpers";
import type { YamlFieldSchemaMap } from "../../../../src/resources/yaml_field_schema";
import { hasAllowUnknownFields } from "../../../../src/resources/yaml_field_schema";
import en from "../../../../src/translations/en.json";
const localize = ((key: string) => key) as HomeAssistant["localize"];
const BUILT_IN_ACTION_TYPES = [
"delay",
"wait_template",
"wait_for_trigger",
"event",
"condition",
"stop",
"repeat",
"choose",
"if",
"sequence",
"parallel",
"variables",
"set_conversation_response",
];
const BUILT_IN_TRIGGER_TYPES = [
"state",
"numeric_state",
"event",
"homeassistant",
"template",
"time",
"time_pattern",
"sun",
"zone",
"tag",
"webhook",
"geo_location",
"calendar",
"persistent_notification",
"conversation",
"device",
];
const BUILT_IN_CONDITION_TYPES = [
"state",
"numeric_state",
"template",
"time",
"sun",
"zone",
"trigger",
"and",
"or",
"not",
"device",
];
describe("haFormSchemaToYamlFieldSchemaMap", () => {
test("maps selector entries straight through", () => {
const schema = [
{ name: "entity_id", required: true, selector: { entity: {} } },
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema)).toEqual({
entity_id: {
required: true,
default: undefined,
description: undefined,
selector: { entity: {} },
},
});
});
test("converts select entries into a select selector", () => {
const schema = [
{
name: "behavior",
type: "select",
options: [
["first", "First"],
["all", "All"],
],
},
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema).behavior.selector).toEqual({
select: {
options: [
{ value: "first", label: "First" },
{ value: "all", label: "All" },
],
},
});
});
test("flattens grid and expandable containers into the parent map", () => {
const schema = [
{
name: "grid",
type: "grid",
schema: [{ name: "above", selector: { number: {} } }],
},
{
name: "advanced",
type: "expandable",
schema: [{ name: "for", selector: { duration: {} } }],
},
] as const satisfies readonly HaFormSchema[];
expect(Object.keys(haFormSchemaToYamlFieldSchemaMap(schema))).toEqual([
"above",
"for",
]);
});
test("uses the description callback when given", () => {
const schema = [
{ name: "entity_id", selector: { entity: {} } },
] as const satisfies readonly HaFormSchema[];
expect(
haFormSchemaToYamlFieldSchemaMap(schema, (field) => `desc:${field}`)
.entity_id.description
).toBe("desc:entity_id");
});
test("skips entries without a name and unsupported types", () => {
const schema = [
{ type: "constant", name: "info", value: "x" },
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema)).toEqual({});
});
});
describe("actionSchemaKey", () => {
test("returns the service name for service calls", () => {
expect(
actionSchemaKey({ action: "light.turn_on" } as unknown as Action)
).toBe("light.turn_on");
});
test("returns the built-in key for built-in actions", () => {
expect(actionSchemaKey({ delay: "00:00:05" } as unknown as Action)).toBe(
"delay"
);
expect(
actionSchemaKey({ choose: [], default: [] } as unknown as Action)
).toBe("choose");
});
test("is stable while the action's values change", () => {
expect(
actionSchemaKey({
action: "light.turn_on",
target: { entity_id: "light.kitchen" },
} as unknown as Action)
).toBe(
actionSchemaKey({
action: "light.turn_on",
target: { entity_id: "light.bedroom" },
data: { brightness: 5 },
} as unknown as Action)
);
});
test("returns undefined for an unrecognized action", () => {
expect(actionSchemaKey({ unknown_thing: 1 } as unknown as Action)).toBe(
undefined
);
});
});
describe("actionToYamlSchema", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: {
selector: { number: { min: 0, max: 255 } },
description: "Brightness",
},
},
target: { entity: [{ domain: ["light"] }] },
},
},
} as unknown as HomeAssistant["services"];
test("builds a service schema for service calls", () => {
const schema = actionToYamlSchema("light.turn_on", services, localize)!;
expect(schema.action.required).toBe(true);
expect(schema.data.fields?.brightness.selector).toEqual({
number: { min: 0, max: 255 },
});
// The service's target filter is forwarded into the entity_id selector.
expect(schema.target.fields?.entity_id.selector).toEqual({
entity: { multiple: true, filter: [{ domain: ["light"] }] },
});
});
test("builds a built-in schema for built-in actions", () => {
expect(actionToYamlSchema("delay", services, localize)).toBe(
builtInActionSchema("delay")
);
});
test("falls back to base fields that allow unknown keys", () => {
const schema = actionToYamlSchema(undefined, services, localize)!;
expect(Object.keys(schema)).toEqual([
"alias",
"enabled",
"continue_on_error",
]);
expect(hasAllowUnknownFields(schema)).toBe(true);
});
test("does not leak the allow-unknown marker into other schemas", () => {
actionToYamlSchema(undefined, services, localize);
expect(hasAllowUnknownFields(builtInActionSchema("delay")!)).toBe(false);
});
});
describe("serviceActionSchema", () => {
test("hoists grouped advanced fields to the top level of data", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: { selector: { number: {} } },
advanced_fields: {
collapsed: true,
fields: { transition: { selector: { number: {} } } },
},
},
},
},
} as unknown as HomeAssistant["services"];
const schema = serviceActionSchema("light", "turn_on", services, localize);
expect(Object.keys(schema.data.fields!)).toEqual([
"brightness",
"transition",
]);
});
test("does not mark a field required when it has a default", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: { required: true, default: 255, selector: {} },
color_name: { required: true, selector: {} },
},
},
},
} as unknown as HomeAssistant["services"];
const fields = serviceActionSchema("light", "turn_on", services, localize)
.data.fields!;
expect(fields.brightness.required).toBe(false);
expect(fields.color_name.required).toBe(true);
});
});
describe("builtInTriggerSchema / builtInConditionSchema", () => {
test("always includes the shared base fields", () => {
expect(Object.keys(builtInTriggerSchema("state", localize)!)).toEqual(
expect.arrayContaining(["trigger", "id", "alias", "enabled", "variables"])
);
expect(Object.keys(builtInConditionSchema("state", localize)!)).toEqual(
expect.arrayContaining(["condition", "alias", "enabled"])
);
});
test("device triggers and conditions accept integration-specific keys", () => {
expect(
hasAllowUnknownFields(builtInTriggerSchema("device", localize)!)
).toBe(true);
expect(
hasAllowUnknownFields(builtInConditionSchema("device", localize)!)
).toBe(true);
});
test("known trigger types do not accept unknown keys", () => {
expect(
hasAllowUnknownFields(builtInTriggerSchema("state", localize)!)
).toBe(false);
});
});
describe("built-in field descriptions", () => {
// Built-in schemas carry translation keys as their description; the hover
// tooltip and completion info resolve them through localize(). A key that
// isn't in en.json would silently render as the raw key.
const resolve = (key: string): unknown =>
key
.split(".")
.reduce<any>((node, part) => (node ? node[part] : undefined), en);
const collect = (map: YamlFieldSchemaMap | undefined, into: string[]) => {
for (const field of Object.values(map ?? {})) {
if (field.description) into.push(field.description);
collect(field.fields, into);
}
return into;
};
const descriptionsOf = (map: YamlFieldSchemaMap | undefined) =>
collect(map, []);
test.each(BUILT_IN_ACTION_TYPES)("action %s", (type) => {
const keys = descriptionsOf(builtInActionSchema(type));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test.each(BUILT_IN_TRIGGER_TYPES)("trigger %s", (type) => {
const keys = descriptionsOf(builtInTriggerSchema(type, localize));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test.each(BUILT_IN_CONDITION_TYPES)("condition %s", (type) => {
const keys = descriptionsOf(builtInConditionSchema(type, localize));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test("service action schema", () => {
const keys = descriptionsOf(
serviceActionSchema(
"light",
"turn_on",
{} as HomeAssistant["services"],
localize
)
);
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
});
@@ -52,6 +52,7 @@ const device = (id: string, overrides: Record<string, unknown> = {}) =>
area_id: null,
entry_type: null,
primary_config_entry: null,
parent_device_id: null,
config_entries: [],
config_entries_subentries: {},
connections: [],
+119
View File
@@ -0,0 +1,119 @@
import { jinja } from "@codemirror/lang-jinja";
import { yaml } from "@codemirror/lang-yaml";
import { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vitest";
import { haYamlLintSource } from "../../src/resources/yaml_ha_completions";
import type { YamlFieldSchemaMap } from "../../src/resources/yaml_field_schema";
import { allowUnknownFields } from "../../src/resources/yaml_field_schema";
// haYamlLintSource only reads `view.state`, so a state-only stub is enough.
const viewFor = (doc: string, language = jinja({ base: yaml() })) =>
({
state: EditorState.create({ doc, extensions: [language] }),
}) as EditorView;
const SCHEMA: YamlFieldSchemaMap = {
trigger: { required: true, selector: { text: null } },
entity_id: { selector: { entity: null } },
options: {
selector: { object: null },
fields: {
above: { required: true, selector: { number: {} } },
below: { selector: { number: {} } },
},
},
};
describe("haYamlLintSource", () => {
test("accepts a document that matches the schema", () => {
expect(
haYamlLintSource(
viewFor("trigger: state\nentity_id: light.kitchen\n"),
SCHEMA
)
).toEqual([]);
});
test("warns about a key the schema doesn't know", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n"),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].severity).toBe("warning");
expect(diagnostics[0].message).toContain("not_a_field");
});
test("errors when a required key is missing", () => {
const diagnostics = haYamlLintSource(
viewFor("entity_id: light.kitchen\n"),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].severity).toBe("error");
expect(diagnostics[0].message).toContain("trigger");
});
test("checks nested mappings against the nested schema", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: numeric_state\noptions:\n bogus: 1\n"),
SCHEMA
);
expect(diagnostics.map((d) => d.severity).sort()).toEqual([
"error",
"warning",
]);
expect(
diagnostics.find((d) => d.severity === "warning")!.message
).toContain("bogus");
// "above" is required inside options.
expect(diagnostics.find((d) => d.severity === "error")!.message).toContain(
"above"
);
});
test("stays quiet about unknown keys on a map marked allowUnknownFields", () => {
const schema = allowUnknownFields({
trigger: { required: true, selector: { text: null } },
});
expect(
haYamlLintSource(
viewFor("trigger: device\ndomain: zha\nintegration_specific: 1\n"),
schema
)
).toEqual([]);
});
test("localizes diagnostics when a localize callback is given", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n"),
SCHEMA,
((key: string, values?: Record<string, string>) =>
`${key}|${values?.field}`) as any
);
expect(diagnostics[0].message).toBe(
"ui.components.yaml-editor.schema.unknown_field|not_a_field"
);
});
test("finds the mapping through a plain yaml() tree too", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n", yaml()),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].message).toContain("not_a_field");
});
test("reports nothing for an empty document", () => {
expect(haYamlLintSource(viewFor(""), SCHEMA)).toEqual([]);
});
});