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
Paul BotteinandGitHub 91a6d737b3 Fix row target badge height and font (#53612) 2026-08-12 10:17:08 +01:00
Petar PetrovandGitHub 22c3a6fe67 Don't trim the selected value in pickers (#53613) 2026-08-12 10:16:39 +01:00
Petar PetrovandGitHub bcc799970a Fix lowercase view button in blueprint in-use dialog (#53609) 2026-08-12 09:55:34 +01:00
Petar PetrovandGitHub 31d4a37c15 Fix time condition summary when before is midnight (#53608) 2026-08-12 09:51:51 +01:00
101 changed files with 5638 additions and 2382 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. */
+1 -2
View File
@@ -472,10 +472,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (disabled) {
return;
}
const newValue = value?.trim();
const newTab = ev.ctrlKey || ev.metaKey;
this._fireSelectedEvents(newValue, index, newTab);
this._fireSelectedEvents(value, index, newTab);
};
private _fireSelectedEvents(value: string, index: number, newTab = false) {
@@ -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}
-1
View File
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+210 -82
View File
@@ -94,6 +94,45 @@ const localizeTimeString = (
}
};
// Seconds since midnight for a literal `HH:MM(:SS)` time, undefined for
// anything else (entity ids contain a dot, and malformed input is ignored).
const literalTimeToSeconds = (value: unknown): number | undefined => {
if (typeof value !== "string" || value.includes(".")) {
return undefined;
}
const chunks = value.split(":");
if (chunks.length < 2 || chunks.length > 3) {
return undefined;
}
const hours = Number(chunks[0]);
const minutes = Number(chunks[1]);
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
if (
!Number.isFinite(hours) ||
!Number.isFinite(minutes) ||
!Number.isFinite(seconds)
) {
return 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
@@ -107,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));
@@ -140,7 +187,7 @@ const tryDescribeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (isTriggerList(trigger)) {
const triggers = ensureArray(trigger.triggers);
@@ -156,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) {
@@ -187,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) {
@@ -218,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(
@@ -255,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`,
@@ -296,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,
@@ -314,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) {
@@ -404,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`,
{
@@ -893,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));
@@ -922,7 +1007,7 @@ const tryDescribeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (typeof condition === "string" && hasTemplate(condition)) {
return hass.localize(
@@ -930,7 +1015,7 @@ const tryDescribeCondition = (
);
}
if (condition.alias && !ignoreAlias) {
if (condition.alias && !options?.ignoreAlias) {
return condition.alias;
}
@@ -952,7 +1037,8 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -978,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);
@@ -1035,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,
@@ -1056,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(
@@ -1093,7 +1163,7 @@ const describeLegacyCondition = (
: state
);
}
} else if (condition.state !== "") {
} else if (condition.state != null && condition.state !== "") {
states.push(
stateObj
? condition.attribute
@@ -1114,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`,
{
@@ -1136,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
@@ -1157,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`,
@@ -1232,12 +1356,16 @@ const describeLegacyCondition = (
let hasTime = "";
if (after !== undefined && before !== undefined) {
if (
typeof condition.after === "string" &&
!condition.after.includes(".") &&
typeof condition.before === "string" &&
!condition.before.includes(".") &&
condition.after > condition.before
const afterSeconds = literalTimeToSeconds(condition.after);
const beforeSeconds = literalTimeToSeconds(condition.before);
if (beforeSeconds === 0) {
// A window ending at midnight runs to the end of the day, so the
// "before" boundary adds nothing to the summary.
hasTime = "after";
} else if (
afterSeconds !== undefined &&
beforeSeconds !== undefined &&
afterSeconds > beforeSeconds
) {
hasTime = "after_before_or";
} else {
+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[]
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
export const getLogbookDataFromServer = (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+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,
+139 -9
View File
@@ -1,9 +1,14 @@
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";
@@ -11,7 +16,9 @@ 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";
@@ -27,6 +34,7 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
href?: string;
}
@customElement("ha-more-info-details")
@@ -41,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];
@@ -55,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">
@@ -70,19 +169,32 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
>
${stateEntries.map(
(entry) =>
html`<ha-list-item-value
.label=${this.hass.localize(entry.translationKey)}
>
${entry.value}
</ha-list-item-value>`
${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
@@ -163,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">
@@ -234,6 +360,10 @@ class HaMoreInfoDetails extends LitElement {
margin-top: var(--ha-space-4);
}
a {
color: var(--primary-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
+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;
};
@@ -622,6 +622,8 @@ export class HaAutomationRowTargets extends LitElement {
var(--ha-color-border-neutral-quiet);
overflow: hidden;
height: 32px;
box-sizing: border-box;
font: inherit;
}
.target.warning {
background: var(--ha-color-fill-warning-normal-resting);
@@ -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
@@ -622,8 +622,7 @@ class HaBlueprintOverview extends LitElement {
}
),
confirmText: this.hass!.localize(
"ui.panel.config.blueprint.overview.blueprint_in_use_view",
{ type }
`ui.panel.config.blueprint.overview.blueprint_in_use_view_${blueprint.domain}`
),
});
if (result) {
@@ -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,
-353
View File
@@ -1,353 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-alert";
import "../../components/ha-relative-time";
import "../../components/ha-spinner";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { fetchDateWS } from "../../data/history";
import type { LogbookEntry } from "../../data/logbook";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import {
buttonLinkStyle,
haStyle,
haStyleDialog,
} from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-chain";
import type { LogbookChain } from "./logbook-chain-resolver";
import { resolveLogbookChain } from "./logbook-chain-resolver";
import type { LogbookItem } from "./logbook-entry-model";
import { computeLogbookItem } from "./logbook-entry-model";
import {
entityNameButtonStyle,
renderEntityName,
transitionArrow,
} from "./logbook-entry-templates";
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
@customElement("dialog-logbook-detail")
class DialogLogbookDetail
extends LitElement
implements HassDialog<LogbookDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LogbookDetailDialogParams;
@state() private _open = false;
@state() private _chain?: LogbookChain;
@state() private _previousState?: string;
@state() private _error = false;
public showDialog(params: LogbookDetailDialogParams): void {
this._params = params;
this._open = true;
this._chain = undefined;
this._previousState = undefined;
this._error = false;
if (
params.entry.context_event_type === "call_service" &&
params.entry.context_domain
) {
this.hass.loadBackendTranslation("services", params.entry.context_domain);
}
this._loadDetails();
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._chain = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
// Both fetches resolve into a single render so the dialog reflows once.
private async _loadDetails() {
const { entry } = this._params!;
const [{ chain, errored }, previousState] = await Promise.all([
this._fetchChain(entry),
this._fetchPreviousState(entry),
]);
if (this._params?.entry !== entry) {
return;
}
this._error = errored;
this._chain = chain;
this._previousState = previousState;
}
private async _fetchChain(
entry: LogbookEntry
): Promise<{ chain: LogbookChain; errored: boolean }> {
const { userIdToName, systemUserIds } = this._params!;
const options = { userIdToName, systemUserIds };
const resolveWithoutFetch = () =>
resolveLogbookChain(this.hass, entry, options, async () => []);
try {
const chain = isComponentLoaded(this.hass.config, "logbook")
? await resolveLogbookChain(this.hass, entry, options)
: await resolveWithoutFetch();
return { chain, errored: false };
} catch {
return { chain: await resolveWithoutFetch(), errored: true };
}
}
// The feed the row was clicked in can be filtered or partially loaded, so
// the state active just before the entry is resolved from history instead.
private async _fetchPreviousState(
entry: LogbookEntry
): Promise<string | undefined> {
if (
!entry.entity_id ||
entry.state === undefined ||
!isComponentLoaded(this.hass.config, "history")
) {
return undefined;
}
const end = new Date(entry.when * 1000);
const start = new Date(end.getTime() - 1);
try {
const states = await fetchDateWS(this.hass, start, end, [
entry.entity_id,
]);
return states[entry.entity_id]?.[0]?.s;
} catch {
// The row is still useful without an old state.
return undefined;
}
}
protected render() {
if (!this._params) {
return nothing;
}
const { entry } = this._params;
const item = computeLogbookItem(this.hass, entry);
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
@closed=${this._dialogClosed}
@hass-more-info=${this._moreInfoOpened}
>
<div class="content">
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
</div>
</ha-adaptive-dialog>
`;
}
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
const stateObj = entry.entity_id
? this.hass.states[entry.entity_id]
: undefined;
const transition = this._transitionValues(item, entry, stateObj);
const when = this._entryDate(item.when);
const subjectKey =
item.category === "entity"
? "entity"
: item.category === "automation"
? entry.domain === "script"
? "script"
: "automation"
: "integration";
return html`
<ha-grouped-list>
<ha-list-item-value
.label=${this.hass.localize(
`ui.dialogs.logbook_detail.${subjectKey}` as LocalizeKeys
)}
>
${renderEntityName(this.hass, item.name, entry.entity_id)}
${
item.context
? html`<span class="sub">${item.context}</span>`
: nothing
}
</ha-list-item-value>
${
transition
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.state"
)}
>
${
transition.oldState
? html`<span class="old-state"
>${transition.oldState}</span
><span class="arrow"
>${transitionArrow(this.hass)}</span
>`
: nothing
}<span class="new-state">${transition.newState}</span>
</ha-list-item-value>
`
: item.value
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.event"
)}
>
${item.value.text}
</ha-list-item-value>
`
: nothing
}
<ha-list-item-value
class="time-value"
.label=${this.hass.localize("ui.dialogs.logbook_detail.time")}
>
${formatDateTimeWithSeconds(when, this.hass.locale, this.hass.config)}
<span class="sub">
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
</span>
</ha-list-item-value>
</ha-grouped-list>
`;
}
private _renderWhatHappened(entry: LogbookEntry) {
return html`
${
this._error
? html`<ha-alert alert-type="warning">
${this.hass.localize("ui.components.logbook.retrieval_error")}
</ha-alert>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
>
<div class="chain-area">
${
this._chain === undefined
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
: html`<ha-logbook-chain
.hass=${this.hass}
.chain=${this._chain}
.subject=${entry}
.traceContexts=${this._params?.traceContexts ?? {}}
></ha-logbook-chain>`
}
</div>
</ha-grouped-list>
`;
}
private _entryDate = memoizeOne((when: number) => new Date(when));
private _transitionValues(
item: LogbookItem,
entry: LogbookEntry,
stateObj?: HassEntity
): { oldState?: string; newState: string } | undefined {
if (item.category !== "entity" || entry.state === undefined) {
return undefined;
}
const newState = stateObj
? this.hass.formatEntityState(stateObj, entry.state)
: entry.state;
const previousState = this._previousState;
const oldState =
previousState !== undefined && previousState !== entry.state
? stateObj
? this.hass.formatEntityState(stateObj, previousState)
: previousState
: undefined;
return { oldState, newState };
}
private _moreInfoOpened() {
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
buttonLinkStyle,
entityNameButtonStyle,
css`
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-list-item-value .name {
font-weight: var(--ha-font-weight-medium);
}
.sub {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.old-state {
color: var(--secondary-text-color);
}
.arrow {
color: var(--disabled-color);
padding: 0 4px;
}
.new-state {
font-weight: var(--ha-font-weight-medium);
}
.time-value {
font-variant-numeric: tabular-nums;
}
ha-relative-time {
display: contents;
}
.chain-area {
display: grid;
}
/* Reserved at two chain rows, the typical chain height, so the swap
from spinner to content barely moves the dialog. Only the spinner
reserves it a resolved chain sizes to its own rows. */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 114px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-logbook-detail": DialogLogbookDetail;
}
}
-448
View File
@@ -1,448 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiStateMachine } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { navigate } from "../../common/navigate";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import { computeServiceLabel } from "../../data/compute-service-info";
import type { LogbookEntry } from "../../data/logbook";
import { createHistoricState, localizeTriggerSource } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import { buttonLinkStyle, haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { LogbookChain } from "./logbook-chain-resolver";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeLogbookItem,
computeTraceLink,
entityDisplay,
isRunRow,
isSameLogbookEntry,
nodeColor,
} from "./logbook-entry-model";
import {
entityNameButtonStyle,
renderEntityName,
renderLogbookCauseIcon,
renderLogbookGlyph,
} from "./logbook-entry-templates";
@customElement("ha-logbook-chain")
class HaLogbookChain extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public chain!: LogbookChain;
@property({ attribute: false }) public subject!: LogbookEntry;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
protected render() {
const { rows, runRow, origins, syntheticRun, triggerRow } = this.chain;
if (!origins.length && !runRow && !syntheticRun && rows.length <= 1) {
return html`
<p class="no-cause">
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
</p>
`;
}
// The chain is the subject's cause path: the runs recorded before its
// rows (user → automation → script → entity). Sibling effects and runs
// recorded after the subject are not causes and stay out.
const isSubjectRow = (row: LogbookEntry) =>
this.subject.entity_id
? row.entity_id === this.subject.entity_id
: isSameLogbookEntry(row, this.subject);
const firstSubjectIndex = rows.findIndex(isSubjectRow);
const visibleRows = rows.filter(
(row, index) =>
isSubjectRow(row) ||
(isRunRow(row) &&
(firstSubjectIndex === -1 || index < firstSubjectIndex))
);
return html`
<div class="chain">
${origins.map((origin) =>
this._renderOriginNode(
origin,
runRow ?? this.subject,
origin.type === "state" ? triggerRow : undefined
)
)}
${syntheticRun ? this._renderSyntheticRunNode(syntheticRun) : nothing}
${visibleRows.map((row) =>
isRunRow(row) ? this._renderRunNode(row) : this._renderEffectNode(row)
)}
</div>
`;
}
private _renderSyntheticRunNode(cause: LogbookCause) {
const sub = this.subject.context_source
? localizeTriggerSource(this.hass.localize, this.subject.context_source)
: this.hass.localize(
cause.type === "script"
? "ui.components.logbook.script_ran"
: "ui.components.logbook.automation_triggered"
);
const traceLink = computeTraceLink(
this.traceContexts,
this.subject.context_id
);
return html`
<div class="chain-row">
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
<span class="chain-content">
${renderEntityName(this.hass, cause.name, cause.entityId)}
${
traceLink
? html`<a
class="trace-link"
href=${traceLink}
@click=${this._traceClicked}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`
: nothing
}
${sub ? html`<span class="chain-secondary">${sub}</span>` : nothing}
</span>
</div>
`;
}
private _renderOriginNode(
origin: LogbookCause,
originRow: LogbookEntry,
triggerRow?: LogbookEntry
) {
const name =
origin.name ||
this.hass.localize("ui.components.logbook.cause.scheduled");
const isState = origin.type === "state";
const triggerState = isState
? (triggerRow?.state ?? originRow.context_state)
: undefined;
const stateObj = origin.entityId
? this.hass.states[origin.entityId]
: undefined;
const triggerValue = triggerState
? stateObj
? this.hass.formatEntityState(stateObj, triggerState)
: triggerState
: undefined;
const secondary = isState
? origin.entityId
? entityDisplay(this.hass, origin.entityId).secondary
: undefined
: this._actionUsedText(originRow);
const isAvatar = origin.type === "user" && !origin.systemUser;
const historicStateObj =
stateObj && triggerState
? createHistoricState(stateObj, triggerState)
: stateObj;
const color =
isState && historicStateObj
? nodeColor("entity", historicStateObj)
: undefined;
return html`
<div class="chain-row">
<span
class="chain-node ${isAvatar ? "avatar" : ""}"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${this._renderOriginIcon(origin, historicStateObj)}
</span>
<span class="chain-content">
${renderEntityName(this.hass, name, origin.entityId)}
${
secondary
? html`<span class="chain-secondary">${secondary}</span>`
: nothing
}
</span>
${
triggerValue
? html`<span class="chain-trailing">
<span class="trailing-state">${triggerValue}</span>
${
triggerRow
? html`<span class="trailing-time"
>${this._formatTimeWithMs(triggerRow.when * 1000)}</span
>`
: nothing
}
</span>`
: nothing
}
</div>
`;
}
private _actionUsedText(originRow: LogbookEntry) {
if (
originRow.context_event_type === "call_service" &&
originRow.context_domain &&
originRow.context_service
) {
return this.hass.localize("ui.dialogs.logbook_detail.action_used", {
name: computeServiceLabel(
this.hass.localize,
this.hass.services,
`${originRow.context_domain}.${originRow.context_service}`
),
});
}
return "";
}
private _renderOriginIcon(origin: LogbookCause, stateObj?: HassEntity) {
if (origin.type === "state") {
return stateObj
? html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
: html`<ha-svg-icon .path=${mdiStateMachine}></ha-svg-icon>`;
}
return renderLogbookCauseIcon(origin);
}
private _formatTimeWithMs(when: number) {
const time = formatTimeWithSeconds(
new Date(when),
this.hass.locale,
this.hass.config
);
const ms = String(Math.floor(when % 1000)).padStart(3, "0");
return `${time}.${ms}`;
}
private _renderRunNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const time = this._formatTimeWithMs(item.when);
const traceLink = computeTraceLink(this.traceContexts, row.context_id);
return html`
<div class="chain-row">
<span class="chain-node run">
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
${renderEntityName(this.hass, item.name, row.entity_id)}
${
traceLink
? html`<a
class="trace-link"
href=${traceLink}
@click=${this._traceClicked}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`
: nothing
}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
</div>
`;
}
private _renderEffectNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const stateObj = row.entity_id
? this.hass.states[row.entity_id]
: undefined;
const historicStateObj = stateObj
? createHistoricState(stateObj, row.state)
: undefined;
const color = nodeColor(item.category, historicStateObj);
const time = this._formatTimeWithMs(item.when);
return html`
<div class="chain-row">
<span
class="chain-node"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
${renderEntityName(this.hass, item.name, row.entity_id)}
${
item.context
? html`<span class="chain-secondary">${item.context}</span>`
: nothing
}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
</div>
`;
}
private _traceClicked(ev: MouseEvent) {
const href = isNavigationClick(ev);
if (!href) {
return;
}
// navigate() closes the dialogs above this chain.
navigate(href);
}
static get styles(): CSSResultGroup {
return [
haStyle,
buttonLinkStyle,
entityNameButtonStyle,
css`
:host {
display: block;
}
.chain-row {
position: relative;
display: flex;
align-items: center;
gap: var(--ha-space-4);
min-height: 56px;
padding: var(--ha-space-2) var(--ha-space-4);
box-sizing: border-box;
}
/* Caret between rows: the chain reads top-down, cause to effects. */
.chain-row + .chain-row::before {
content: "";
position: absolute;
top: -3px;
inset-inline-start: 27px;
border-inline-start: 5px solid transparent;
border-inline-end: 5px solid transparent;
border-top: 6px solid var(--divider-color);
}
.chain-node {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--card-background-color);
color: var(--node-color, var(--secondary-text-color));
--mdc-icon-size: 18px;
}
.chain-node state-badge {
margin: 0;
color: inherit;
}
.chain-node::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0.15;
}
.chain-node > * {
position: relative;
}
.chain-node.run {
color: var(
--logbook-category-automation-color,
var(--light-blue-color)
);
border-radius: var(--ha-border-radius-md);
}
.chain-node.avatar::before {
display: none;
}
.chain-node.avatar ha-user-badge {
width: 32px;
height: 32px;
font-size: 14px;
}
.chain-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.chain-content .name {
font-weight: var(--ha-font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
.chain-secondary {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.chain-trailing {
text-align: end;
flex-shrink: 0;
}
.trailing-state {
display: block;
}
.trailing-time {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-variant-numeric: tabular-nums;
}
.trace-link {
flex-shrink: 0;
color: var(--primary-color);
font-size: var(--ha-font-size-s);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
.no-cause {
margin: 0;
padding: var(--ha-space-3) var(--ha-space-4);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-logbook-chain": HaLogbookChain;
}
}
+229 -121
View File
@@ -1,50 +1,58 @@
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-relative-time";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-tooltip";
import "../../components/user/ha-user-badge";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { User } from "../../data/user";
import { buttonLinkStyle, haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type {
LogbookCause,
LogbookCauseType,
LogbookGlyph,
LogbookItem,
LogbookNameDetail,
LogbookValue,
} from "./logbook-entry-model";
import { computeLogbookItem, nodeColor } from "./logbook-entry-model";
import {
renderLogbookCauseIcon,
renderLogbookGlyph,
transitionArrow,
} from "./logbook-entry-templates";
computeLogbookItem,
nodeColor,
TRIGGER_DOMAINS,
} from "./logbook-entry-model";
type EntryLayout = "timeline" | "list" | "inline";
interface LogbookRenderItem extends LogbookItem {
traceLink: string | undefined;
renderedTime: TemplateResult | string;
renderedValue: TemplateResult | string;
}
export interface LogbookEntrySelectedDetail {
item: LogbookEntry;
}
declare global {
interface HASSDomEvents {
"logbook-entry-selected": LogbookEntrySelectedDetail;
"logbook-toggle-time": undefined;
}
}
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
@customElement("ha-logbook-entry")
class HaLogbookEntry extends LitElement {
@@ -57,7 +65,7 @@ class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ type: Boolean }) public narrow = false;
@@ -91,6 +99,17 @@ class HaLogbookEntry extends LitElement {
systemUserIds: this.systemUserIds,
});
const traceContext =
entry.domain &&
TRIGGER_DOMAINS.includes(entry.domain) &&
entry.context_id &&
entry.context_id in this.traceContexts
? this.traceContexts[entry.context_id]
: undefined;
const traceLink = traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
const hideName = this.nameDetail === "none";
const layout: EntryLayout =
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
@@ -106,8 +125,9 @@ class HaLogbookEntry extends LitElement {
const ctx: LogbookRenderItem = {
...item,
traceLink,
renderedTime,
renderedValue: this._renderValue(item.value, seenEntityIds),
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
};
return html`
@@ -154,25 +174,33 @@ class HaLogbookEntry extends LitElement {
`;
}
private _toggleTime() {
fireEvent(this, "logbook-toggle-time");
private _toggleTime(e: Event) {
e.stopPropagation();
fireEvent(this, "logbook-toggle-time" as any);
}
private _timeKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fireEvent(this, "logbook-toggle-time");
fireEvent(this, "logbook-toggle-time" as any);
}
}
private _showDetail() {
fireEvent(this, "logbook-entry-selected", { item: this.item });
private _handleTraceClick(ev: MouseEvent) {
// Let modified clicks open in a new tab; otherwise route in-app.
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
return;
}
ev.preventDefault();
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
fireEvent(this, "closed");
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) return;
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", { entityId });
}
@@ -189,40 +217,38 @@ class HaLogbookEntry extends LitElement {
private _renderTrailing(
cause: LogbookCause | undefined,
traceLink: string | undefined,
renderedTime: TemplateResult | string
) {
return html`<span class="trailing">
${cause ? this._renderCauseBadge(cause) : nothing}
${
cause
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${this._renderCauseIcon(cause)}</span
>`
: nothing
}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
${this._renderTimeChip(renderedTime)}
</span>`;
}
private _renderCauseBadge(cause: LogbookCause) {
const icon = renderLogbookCauseIcon(cause);
if (this.noDetail) {
return html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge">${icon}</span>`;
}
return html`<button
class="cause-badge"
aria-label=${this.hass.localize("ui.components.logbook.view_details")}
@click=${this._showDetail}
>
${icon}
</button>`;
}
private _renderDetailsLink() {
if (this.noDetail) {
return nothing;
}
return html`<button class="link details-link" @click=${this._showDetail}>
${this.hass.localize("ui.components.logbook.view_details")}
</button>`;
private _renderTraceLink(traceLink: string) {
return html`<a
class="trace-link"
href=${traceLink}
@click=${this._handleTraceClick}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
private _renderTimeline(ctx: LogbookRenderItem) {
const hideName = this.nameDetail === "none";
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const valueIsState = ctx.value?.type === "state";
const causePhrase = ctx.cause
? this._renderCausePhrase(ctx.cause)
@@ -237,9 +263,7 @@ class HaLogbookEntry extends LitElement {
>${
ctx.renderedValue
? valueIsState
? html`<span class="arrow"
>${transitionArrow(this.hass)}</span
>`
? html`<span class="arrow">${rtl ? "←" : "→"}</span>`
: " "
: nothing
}`
@@ -255,10 +279,15 @@ class HaLogbookEntry extends LitElement {
: nothing
}
${
causePhrase
causePhrase || ctx.traceLink
? html`<div class="secondary">
<span class="cause-phrase">${causePhrase}</span>
${this.noDetail ? nothing : html`·`} ${this._renderDetailsLink()}
${
causePhrase
? html`<span class="cause-phrase">${causePhrase}</span>`
: nothing
}
${causePhrase && ctx.traceLink ? html`·` : nothing}
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
</div>`
: nothing
}
@@ -266,8 +295,11 @@ class HaLogbookEntry extends LitElement {
}
private _renderList(ctx: LogbookRenderItem) {
const cause = ctx.cause;
const showThirdLine = this.showCause && cause;
const cause =
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
const trailingTrace = this.showCause ? undefined : ctx.traceLink;
const thirdLineTrace = this.showCause ? ctx.traceLink : undefined;
const showThirdLine = this.showCause && (cause || thirdLineTrace);
return html`
<div class="primary">
<span class="subject"
@@ -281,22 +313,26 @@ class HaLogbookEntry extends LitElement {
<span class="secondary-text">${ctx.context ?? nothing}</span>
${this._renderTrailing(
showThirdLine ? undefined : cause,
trailingTrace,
ctx.renderedTime
)}
</div>
${
showThirdLine
? html`<div class="secondary">
${this._renderListCauseLine(cause)} ${this._renderDetailsLink()}
${this._renderListCauseLine(cause, thirdLineTrace)}
</div>`
: nothing
}
`;
}
private _renderListCauseLine(cause: LogbookCause | undefined) {
private _renderListCauseLine(
cause: LogbookCause | undefined,
traceLink: string | undefined
) {
if (!cause) {
return nothing;
return traceLink ? this._renderTraceLink(traceLink) : nothing;
}
const { localize } = this.hass;
if (cause.entityId) {
@@ -312,8 +348,8 @@ class HaLogbookEntry extends LitElement {
}),
};
const prefix = prefixMap[cause.type];
return html`<span class="cause-line">
${prefix ?? nothing}
return html`
${prefix ? html`<span class="cause-prefix">${prefix}</span>` : nothing}
<button
class="link cause-entity"
@click=${this._entityClicked}
@@ -321,37 +357,45 @@ class HaLogbookEntry extends LitElement {
>
${cause.name}
</button>
</span>`;
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
return html`<span class="cause-line"
>${this._renderCausePhrase(cause)}</span
>`;
return html`
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
private _renderInline(ctx: LogbookRenderItem) {
return html`
<div class="primary">
<span class="primary-text">${ctx.renderedValue}</span>
${this._renderTrailing(ctx.cause, ctx.renderedTime)}
${this._renderTrailing(
ctx.category === "entity" ? ctx.cause : undefined,
ctx.traceLink,
ctx.renderedTime
)}
</div>
`;
}
private _renderValue(
value: LogbookValue | undefined,
seenEntityIds: string[]
seenEntityIds: string[],
noLink: boolean
): TemplateResult | string {
if (!value) {
return "";
}
return value.type === "message"
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
: value.text;
}
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined
entityName: string | undefined,
noLink?: boolean
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
@@ -362,13 +406,15 @@ class HaLogbookEntry extends LitElement {
if (!hasState) {
return displayName;
}
return html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
return noLink
? displayName
: html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
@@ -424,9 +470,54 @@ class HaLogbookEntry extends LitElement {
}
}
private _renderCauseIcon(cause: LogbookCause) {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return nothing;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[]
seenEntities: string[],
noLink?: boolean
) {
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
@@ -442,7 +533,8 @@ class HaLogbookEntry extends LitElement {
return html`${messageParts.join(" ")}
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name
this.hass.states[entityId].attributes.friendly_name,
noLink
)}
${messageEnd.join(" ")}`;
}
@@ -479,11 +571,49 @@ class HaLogbookEntry extends LitElement {
const unavailable =
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
return html`<div class="node-glyph" style=${style}>
${renderLogbookGlyph(this.hass, this.item, item.glyph)}
${this._renderGlyph(item.glyph)}
${unavailable ? html`<span class="node-badge"></span>` : nothing}
</div>`;
}
private _renderGlyph(glyph: LogbookGlyph) {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${this._brandImage(glyph.domain)}
.stateColor=${false}
></state-badge>`;
}
private _brandImage(domain?: string): string | undefined {
if (
!domain ||
this.item.icon ||
this.item.state ||
!isComponentLoaded(this.hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -821,42 +951,12 @@ class HaLogbookEntry extends LitElement {
align-items: center;
}
button.cause-badge {
border: none;
background: none;
color: inherit;
font: inherit;
/* Grow the hit target without moving the icons. */
padding: var(--ha-space-2);
margin: calc(-1 * var(--ha-space-2));
border-radius: var(--ha-border-radius-pill);
cursor: pointer;
}
button.cause-badge:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.06);
}
button.cause-badge:focus-visible {
outline: 2px solid var(--primary-color);
}
.details-link {
flex-shrink: 0;
}
button.link.details-link {
color: var(--primary-color);
font-weight: var(--ha-font-weight-normal);
}
ha-relative-time {
display: contents;
}
.time-chip {
flex-shrink: 0;
min-width: 4.5em;
text-align: end;
line-height: 1;
font-size: var(--ha-font-size-s);
@@ -866,6 +966,10 @@ class HaLogbookEntry extends LitElement {
user-select: none;
}
.time-chip {
min-width: 4.5em;
}
.entry.time-am-pm .time-chip {
min-width: 6em;
}
@@ -887,29 +991,33 @@ class HaLogbookEntry extends LitElement {
font-size: 9px;
}
/* The cause reads as one sentence on one line: it truncates as a
whole and only the details link is kept out of the ellipsis. */
.cause-phrase {
flex: 0 1 auto;
min-width: 0;
.cause-prefix {
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cause-line {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cause-entity {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
/* The trace link sits after the cause; it never shrinks, so a long
cause truncates instead. */
.trace-link {
flex-shrink: 0;
color: var(--primary-color);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
/* Entity names read as the subject, not a wall of blue links the
colored node is the scan anchor. */
button.link {
+6 -37
View File
@@ -6,7 +6,6 @@ import { customElement, eventOptions, property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
@@ -14,14 +13,13 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-entry";
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { sameDay } from "./logbook-entry-model";
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
declare global {
interface HASSDomEvents {
"hass-logbook-live": { enable: boolean };
"logbook-toggle-time": undefined;
}
}
@@ -34,7 +32,6 @@ class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
// Not rendered by rows; read at click time and handed to the detail dialog.
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@@ -52,8 +49,6 @@ class HaLogbookRenderer extends LitElement {
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
false;
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ type: String, attribute: "name-detail" })
public nameDetail?: LogbookNameDetail;
@@ -82,9 +77,7 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("traceContexts") ||
changedProps.has("_showRelative" as never) ||
languageChanged
);
@@ -104,7 +97,6 @@ class HaLogbookRenderer extends LitElement {
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-toggle-time=${this._handleToggleTime}
@logbook-entry-selected=${this._handleEntrySelected}
>
${
this.virtualize
@@ -113,13 +105,7 @@ class HaLogbookRenderer extends LitElement {
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${
this._getRenderRow(
this._showRelative,
this.userIdToName,
this.systemUserIds
) as any
}
.renderItem=${this._getRenderRow(this._showRelative) as any}
>
</lit-virtualizer>`
: this.entries.map((item, index) => this._renderItem(item, index))
@@ -128,16 +114,9 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Memoized on every input the rows render from, so the virtualizer sees a
// new renderItem and refreshes already-rendered rows when one changes.
private _getRenderRow = memoizeOne(
(
_showRelative: boolean,
_userIdToName: Record<string, string>,
_systemUserIds: Set<string>
) =>
(item: LogbookEntry, index: number) =>
this._renderItem(item, index)
(_showRelative: boolean) => (item: LogbookEntry, index: number) =>
this._renderItem(item, index)
);
private _renderItem = (item: LogbookEntry, index: number) => {
@@ -162,6 +141,7 @@ class HaLogbookRenderer extends LitElement {
.item=${item}
.userIdToName=${this.userIdToName}
.systemUserIds=${this.systemUserIds}
.traceContexts=${this.traceContexts}
.narrow=${this.narrow}
.noIcon=${this.noIcon}
.graphColor=${this.graphColor}
@@ -170,7 +150,6 @@ class HaLogbookRenderer extends LitElement {
.lastOfDay=${lastOfDay}
.showRelative=${this._showRelative}
.showCause=${this.showCause}
.noDetail=${this.noDetail}
></ha-logbook-entry>
</div>
`;
@@ -180,16 +159,6 @@ class HaLogbookRenderer extends LitElement {
this._showRelative = !this._showRelative;
}
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
ev.stopPropagation();
showLogbookDetailDialog(this, {
entry: ev.detail.item,
traceContexts: this.traceContexts,
userIdToName: this.userIdToName,
systemUserIds: this.systemUserIds,
});
}
private _formatDateHeader(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -1,184 +0,0 @@
import type { LogbookEntry } from "../../data/logbook";
import { getLogbookDataFromServer } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeContextCause,
computeLogbookCause,
computeUserCause,
isRunCause,
isRunRow,
isSameLogbookEntry,
} from "./logbook-entry-model";
// No run start is available and a delayed script can start long before the
// clicked entry.
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
const WHEN_EPSILON = 0.001;
const MAX_RUN_CANDIDATES = 3;
export interface LogbookChain {
rows: LogbookEntry[];
runRow?: LogbookEntry;
// Causes shown above the run, topmost first.
origins: LogbookCause[];
// Stands in for the run row when it could not be fetched.
syntheticRun?: LogbookCause;
// The state change that fired a state trigger in `origins`.
triggerRow?: LogbookEntry;
}
export type LogbookFetcher = (
startDate: string,
endDate?: string,
entityIds?: string[],
contextId?: string
) => Promise<LogbookEntry[]>;
interface ResolveOptions {
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
const lookbackIso = (when: number) =>
new Date(when * 1000 - LOOKBACK_MS).toISOString();
const justAfterIso = (when: number) =>
new Date(when * 1000 + 1000).toISOString();
// Effect rows never carry a context_id, run rows do: the logbook processor
// only emits it on rows described by the automation/script platforms. This
// candidate discovery + verification is a workaround for that gap; once core
// emits context_id on described rows too, the fast path by entry.context_id
// covers every click and this can be deleted.
const resolveRowsByContextEntity = async (
entry: LogbookEntry,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry[]> => {
const contextEntityId = entry.context_entity_id!;
const runs = (
await fetchEvents(lookbackIso(entry.when), justAfterIso(entry.when), [
contextEntityId,
])
)
.filter((row) => row.context_id && row.when <= entry.when + WHEN_EPSILON)
.sort((a, b) => b.when - a.when)
.slice(0, MAX_RUN_CANDIDATES);
for (const run of runs) {
// eslint-disable-next-line no-await-in-loop
const rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
run.context_id
);
if (rows.some((row) => isSameLogbookEntry(row, entry))) {
return rows;
}
}
return [];
};
const resolveTriggerRow = async (
entityId: string,
beforeWhen: number,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry | undefined> => {
const rows = await fetchEvents(
lookbackIso(beforeWhen),
justAfterIso(beforeWhen),
[entityId]
);
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.state !== undefined && row.when <= beforeWhen + WHEN_EPSILON) {
return row;
}
}
return undefined;
};
export const resolveLogbookChain = async (
hass: HomeAssistant,
entry: LogbookEntry,
options: ResolveOptions = {},
fetchEvents: LogbookFetcher = (startDate, endDate, entityIds, contextId) =>
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId)
): Promise<LogbookChain> => {
const userIdToName = options.userIdToName ?? {};
const { systemUserIds } = options;
let rows: LogbookEntry[] = [];
if (entry.context_id) {
rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
entry.context_id
);
} else if (entry.context_entity_id) {
rows = await resolveRowsByContextEntity(entry, fetchEvents);
}
if (!rows.length) {
rows = [entry];
}
let runRow = rows.find(isRunRow);
if (runRow && runRow !== entry && isSameLogbookEntry(entry, runRow)) {
// The clicked feed copy carries the call_service description that the
// fetched copy of the run row never has.
rows = rows.map((row) => (row === runRow ? entry : row));
runRow = entry;
}
const origins: LogbookCause[] = [];
let syntheticRun: LogbookCause | undefined;
if (runRow) {
const runCause = computeLogbookCause(
hass,
runRow,
userIdToName,
systemUserIds
);
if (runCause?.type !== "user" && !isSameLogbookEntry(entry, runRow)) {
// The run row is its own context origin and comes back without the
// user its effects carry: read the user from the clicked entry.
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
if (userCause) {
origins.push(userCause);
}
}
if (runCause) {
origins.push(runCause);
}
} else {
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
const contextCause = computeContextCause(hass, entry);
if (isRunCause(contextCause)) {
syntheticRun = contextCause;
if (userCause) {
origins.push(userCause);
}
} else {
const cause = userCause ?? contextCause;
if (cause) {
origins.push(cause);
}
}
}
const stateOrigin = origins.find(
(cause) => cause.type === "state" && cause.entityId
);
const triggerRow = stateOrigin
? await resolveTriggerRow(
stateOrigin.entityId!,
(runRow ?? entry).when,
fetchEvents
)
: undefined;
return { rows, runRow, origins, syntheticRun, triggerRow };
};
+21 -68
View File
@@ -12,14 +12,13 @@ import {
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { HomeAssistant } from "../../types";
export type LogbookEntryCategory = "entity" | "automation" | "integration";
const TRIGGER_DOMAINS = ["automation", "script"];
export const TRIGGER_DOMAINS = ["automation", "script"];
const stripEntityId = (message: string, entityId?: string) =>
export const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
export const classifyLogbookEntry = (
@@ -36,10 +35,6 @@ export const classifyLogbookEntry = (
return "integration";
};
// A run row is the acting automation or script; every other row is an effect.
export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
@@ -101,31 +96,12 @@ export const entityDisplay = (
return { primary, secondary };
};
const hasContext = (item: LogbookEntry) =>
export const hasContext = (item: LogbookEntry) =>
item.context_event_type || item.context_state || item.context_message;
export const sameDay = (a?: LogbookEntry, b?: LogbookEntry) =>
!!a?.when && !!b?.when && isSameDay(a.when * 1000, b.when * 1000);
export const isSameLogbookEntry = (a: LogbookEntry, b: LogbookEntry) =>
a.when === b.when &&
a.entity_id === b.entity_id &&
a.state === b.state &&
a.message === b.message &&
a.name === b.name;
// Every entry of a run shares the run's context id, so effect rows resolve
// to their cause's trace too.
export const computeTraceLink = (
traceContexts: TraceContexts,
contextId?: string
): string | undefined => {
const traceContext = contextId ? traceContexts[contextId] : undefined;
return traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
};
// Unavailable is flagged with an orange badge by the row, not a color change.
export const nodeColor = (
category: LogbookEntryCategory,
@@ -155,7 +131,8 @@ export interface LogbookCause {
brandDomain?: string;
}
export const computeUserCause = (
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
@@ -163,21 +140,15 @@ export const computeUserCause = (
const userName = item.context_user_id
? userIdToName[item.context_user_id]
: undefined;
if (!userName) {
return undefined;
if (userName) {
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
}
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
};
export const computeContextCause = (
hass: HomeAssistant,
item: LogbookEntry
): LogbookCause | undefined => {
if (
item.context_event_type === "automation_triggered" ||
item.context_event_type === "script_started"
@@ -268,18 +239,6 @@ export const computeContextCause = (
return undefined;
};
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined =>
computeUserCause(item, userIdToName, systemUserIds) ??
computeContextCause(hass, item);
export const isRunCause = (cause?: LogbookCause): boolean =>
cause?.type === "automation" || cause?.type === "script";
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
@@ -325,11 +284,10 @@ const computeLogbookValue = (
type: "state",
};
}
// Core sends run rows (carrying the automation/script entity) with a raw
// English message; use our own label. Domain-only entries (e.g. logbook.log)
// keep their custom message.
const isAutomationRun =
item.entity_id && domain && TRIGGER_DOMAINS.includes(domain);
domain &&
TRIGGER_DOMAINS.includes(domain) &&
(item.source || hasContext(item) || !!item.context_user_id);
if (isAutomationRun) {
return {
text: hass.localize(
@@ -390,13 +348,6 @@ export const computeLogbookItem = (
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
: undefined;
const userCause = computeUserCause(
entry,
opts.userIdToName ?? {},
opts.systemUserIds
);
const contextCause = computeContextCause(hass, entry);
return {
category,
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
@@ -404,10 +355,12 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
// A row shows the run over the user who started it; the dialog shows both.
cause: isRunCause(contextCause)
? contextCause
: (userCause ?? contextCause),
cause: computeLogbookCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
when: entry.when * 1000,
};
};
@@ -1,177 +0,0 @@
import {
mdiCast,
mdiClockOutline,
mdiCloud,
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiStateMachine,
} from "@mdi/js";
import { css, html } from "lit";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/user/ha-user-badge";
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
import type { LogbookEntry } from "../../data/logbook";
import type { User } from "../../data/user";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { LogbookCause, LogbookGlyph } from "./logbook-entry-model";
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
export const renderLogbookCauseIcon = (cause: LogbookCause) => {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiStateMachine}
></ha-svg-icon>`;
}
if (cause.type === "scheduled") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiClockOutline}
></ha-svg-icon>`;
}
if (cause.type === "homeassistant") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiHomeAssistant}
></ha-svg-icon>`;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
};
const brandImage = (
hass: HomeAssistant,
entry: LogbookEntry,
domain?: string
): string | undefined => {
if (
!domain ||
entry.icon ||
entry.state ||
!isComponentLoaded(hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
);
};
const entityNameClicked = (ev: Event) => {
const target = ev.currentTarget as HTMLElement & { entityId?: string };
if (!target.entityId) {
return;
}
fireEvent(target, "hass-more-info", { entityId: target.entityId });
};
// The event bubbles from the button, so an enclosing dialog can react to it
// (ha-adaptive-dialog closes on hass-more-info).
export const renderEntityName = (
hass: HomeAssistant,
name: string | undefined,
entityId?: string
) => {
if (entityId && entityId in hass.states) {
return html`<button
class="link name"
.entityId=${entityId}
@click=${entityNameClicked}
>
${name}
</button>`;
}
return html`<span class="name">${name}</span>`;
};
export const entityNameButtonStyle = css`
button.link.name {
color: var(--primary-text-color);
text-align: inherit;
text-decoration: none;
}
button.link.name:hover {
text-decoration: underline;
}
`;
export const transitionArrow = (hass: HomeAssistant) =>
computeRTL(hass.language, hass.translationMetadata.translations) ? "←" : "→";
export const renderLogbookGlyph = (
hass: HomeAssistant,
entry: LogbookEntry,
glyph: LogbookGlyph
) => {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${brandImage(hass, entry, glyph.domain)}
.stateColor=${false}
></state-badge>`;
};
@@ -1,23 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
export interface LogbookDetailDialogParams {
entry: LogbookEntry;
traceContexts?: TraceContexts;
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
export const loadLogbookDetailDialog = () => import("./dialog-logbook-detail");
export const showLogbookDetailDialog = (
element: HTMLElement,
params: LogbookDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-logbook-detail",
dialogImport: loadLogbookDetailDialog,
dialogParams: params,
});
};
@@ -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;
}
+187 -32
View File
@@ -694,7 +694,6 @@
"automation_triggered": "Triggered",
"script_ran": "Ran",
"view_trace": "View trace",
"view_details": "View details",
"trigger_type": {
"calendar": "[%key:ui::panel::config::automation::editor::triggers::type::calendar::label%]",
"conversation": "[%key:ui::panel::config::automation::editor::triggers::type::conversation::label%]",
@@ -703,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%]",
@@ -1436,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",
@@ -1668,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",
@@ -2050,19 +2063,6 @@
"description": "Description",
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
},
"logbook_detail": {
"title": "Activity details",
"entity": "Entity",
"automation": "Automation",
"script": "Script",
"integration": "Integration",
"state": "State",
"event": "Event",
"time": "Time",
"what_happened": "What happened",
"no_known_cause": "No cause was recorded for this activity.",
"action_used": "Action used: {name}"
},
"voice-settings": {
"expose_header": "Expose",
"aliases_header": "Aliases",
@@ -2087,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.",
@@ -2097,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": {
@@ -2783,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",
@@ -5362,7 +5363,7 @@
}
},
"state": {
"label": "State",
"label": "State changed",
"attribute": "Attribute (optional)",
"from": "From (optional)",
"for": "For",
@@ -5370,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": {
@@ -5400,7 +5402,7 @@
}
},
"numeric_state": {
"label": "Numeric state",
"label": "Numeric state crossed threshold",
"above": "Above",
"below": "Below",
"lower_limit": "Lower limit",
@@ -5412,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": {
@@ -5656,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%]",
@@ -5666,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": {
@@ -5678,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": {
@@ -6041,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%]",
@@ -6145,7 +6297,8 @@
"error": "{path} could not be loaded",
"blueprint_in_use_title": "This blueprint is in use and cannot be deleted",
"blueprint_in_use_text": "Please remove all below {type} that use this blueprint before deleting it. {list}",
"blueprint_in_use_view": "view {type}",
"blueprint_in_use_view_automation": "View automations",
"blueprint_in_use_view_script": "View scripts",
"confirm_delete_title": "Delete blueprint?",
"confirm_delete_text": "{name} will be permanently deleted.",
"add_blueprint": "Import blueprint",
@@ -6799,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",
@@ -11496,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");
});
});
@@ -0,0 +1,76 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import { describeCondition } 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: {},
} as unknown as HomeAssistant;
const describeTimeCondition = (after?: string, before?: string) =>
describeCondition({ condition: "time", after, before }, hass, []);
describe("time condition description", () => {
it("joins a window within one day with 'and'", () => {
expect(describeTimeCondition("09:00:00", "17:00:00")).toBe(
"If the time is after 09:00 and before 17:00"
);
});
it("joins a window crossing midnight with 'or'", () => {
expect(describeTimeCondition("22:00:00", "06:00:00")).toBe(
"If the time is after 22:00 or before 06:00"
);
});
it("omits a 'before' boundary of midnight, which ends the window at the end of the day", () => {
expect(describeTimeCondition("10:00:00", "00:00:00")).toBe(
"If the time is after 10:00"
);
});
it("compares times numerically, not lexicographically", () => {
expect(describeTimeCondition("9:00:00", "10:00:00")).toBe(
"If the time is after 09:00 and before 10:00"
);
});
it("does not compare entity references", () => {
expect(describeTimeCondition("input_datetime.wake_up", "10:00:00")).toBe(
"If the time is after entity input_datetime.wake_up and before 10:00"
);
});
});
+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" },
@@ -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"));
});
});
@@ -1,319 +0,0 @@
import { describe, expect, it } from "vitest";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { LogbookFetcher } from "../../../src/panels/logbook/logbook-chain-resolver";
import { resolveLogbookChain } from "../../../src/panels/logbook/logbook-chain-resolver";
import type { HomeAssistant } from "../../../src/types";
const hass = {
language: "en",
translationMetadata: { translations: {} },
states: {},
entities: {},
devices: {},
areas: {},
floors: {},
localize: () => "",
} as unknown as HomeAssistant;
const USERS = { user_1: "Alice" };
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
when: 0,
name: "",
...partial,
});
const runRow = (when: number, contextId: string): LogbookEntry =>
entry({
when,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
source: "state of binary_sensor.motion",
context_id: contextId,
});
const effectRow = (when: number): LogbookEntry =>
entry({
when,
name: "Ceiling light",
entity_id: "light.ceiling",
state: "on",
context_event_type: "automation_triggered",
context_name: "Wake up",
context_entity_id: "automation.wake_up",
context_source: "state of binary_sensor.motion",
});
interface FetchCall {
entityIds?: string[];
contextId?: string;
}
const makeFetcher = (
handler: (entityIds?: string[], contextId?: string) => LogbookEntry[]
): { fetch: LogbookFetcher; calls: FetchCall[] } => {
const calls: FetchCall[] = [];
return {
calls,
fetch: async (_start, _end, entityIds, contextId) => {
calls.push({ entityIds, contextId });
return handler(entityIds, contextId);
},
};
};
describe("resolveLogbookChain", () => {
it("fetches by context id when the entry has one", async () => {
const run = runRow(10, "ctx_run");
const effect = { ...effectRow(11), context_id: "ctx_run" };
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
if (contextId === "ctx_run") {
return [run, effect];
}
if (entityIds?.includes("binary_sensor.motion")) {
return [
entry({ when: 9.8, entity_id: "binary_sensor.motion", state: "on" }),
];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
// One context fetch + one trigger fetch: no candidate discovery.
expect(calls).toHaveLength(2);
expect(calls[0].contextId).toBe("ctx_run");
expect(chain.runRow).toBe(run);
expect(chain.rows).toEqual([run, effect]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("state");
expect(chain.origins[0].entityId).toBe("binary_sensor.motion");
expect(chain.syntheticRun).toBeUndefined();
expect(chain.triggerRow?.when).toBe(9.8);
});
it("resolves the run through the context entity and verifies candidates", async () => {
const effect = effectRow(11);
const otherRun = runRow(10.9, "ctx_other");
const goodRun = runRow(10, "ctx_good");
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
if (entityIds?.includes("automation.wake_up")) {
return [goodRun, otherRun];
}
if (contextId === "ctx_other") {
// The closest run does not contain the clicked entry.
return [
otherRun,
entry({ when: 11, entity_id: "light.desk", state: "on" }),
];
}
if (contextId === "ctx_good") {
return [goodRun, effect];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(calls[0].entityIds).toEqual(["automation.wake_up"]);
expect(calls[1].contextId).toBe("ctx_other");
expect(calls[2].contextId).toBe("ctx_good");
expect(chain.runRow?.context_id).toBe("ctx_good");
expect(chain.rows).toEqual([goodRun, effect]);
});
it("falls back to a synthetic run when no candidate matches", async () => {
const effect = effectRow(11);
const { fetch } = makeFetcher((entityIds) =>
entityIds?.includes("automation.wake_up")
? [runRow(10, "ctx_unrelated")]
: []
);
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(chain.runRow).toBeUndefined();
expect(chain.rows).toEqual([effect]);
expect(chain.syntheticRun?.type).toBe("automation");
expect(chain.origins).toEqual([]);
});
it("keeps a direct user action as the only origin", async () => {
const direct = entry({
when: 5,
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
});
const { fetch, calls } = makeFetcher(() => []);
const chain = await resolveLogbookChain(
hass,
direct,
{ userIdToName: USERS },
fetch
);
expect(calls).toHaveLength(0);
expect(chain.rows).toEqual([direct]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
expect(chain.syntheticRun).toBeUndefined();
});
it("picks the last trigger state before the run, even minutes earlier", async () => {
const run = runRow(600, "ctx_run");
const effect = { ...effectRow(601), context_id: "ctx_run" };
const { fetch } = makeFetcher((entityIds, contextId) => {
if (contextId === "ctx_run") {
return [run, effect];
}
if (entityIds?.includes("binary_sensor.motion")) {
return [
entry({ when: 100, entity_id: "binary_sensor.motion", state: "on" }),
entry({ when: 480, entity_id: "binary_sensor.motion", state: "off" }),
entry({ when: 640, entity_id: "binary_sensor.motion", state: "on" }),
];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(chain.triggerRow?.when).toBe(480);
expect(chain.triggerRow?.state).toBe("off");
});
it("prefers the clicked copy of the run row over the fetched one", async () => {
const clicked = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "automation",
context_service: "trigger",
});
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = { ...effectRow(11), context_id: "ctx_run" };
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
clicked,
{ userIdToName: USERS },
fetch
);
expect(chain.runRow).toBe(clicked);
expect(chain.rows).toEqual([clicked, effect]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
});
it("surfaces the integration that triggered the run", async () => {
const clicked = entry({
when: 20,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_event_type: "call_service",
context_domain: "homekit",
context_service: "turn_on",
});
const fetched = entry({
when: 20,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = { ...effectRow(21), context_id: "ctx_run" };
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(hass, clicked, {}, fetch);
expect(chain.runRow).toBe(clicked);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("integration");
expect(chain.origins[0].brandDomain).toBe("homekit");
});
it("keeps the user above a run row that does not carry one", async () => {
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = {
...effectRow(11),
context_id: "ctx_run",
context_user_id: "user_1",
};
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
effect,
{ userIdToName: USERS },
fetch
);
expect(chain.runRow).toBe(fetched);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
});
it("does not stack the user twice when the run row resolves it", async () => {
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_user_id: "user_1",
});
const effect = {
...effectRow(11),
context_id: "ctx_run",
context_user_id: "user_1",
};
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
effect,
{ userIdToName: USERS },
fetch
);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
});
});
+1 -144
View File
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import {
computeLogbookItem,
computeTraceLink,
classifyLogbookEntry,
entityDisplay,
computeLogbookCause,
computeLogbookGlyph,
isSameLogbookEntry,
} from "../../../src/panels/logbook/logbook-entry-model";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { HomeAssistant } from "../../../src/types";
@@ -243,7 +241,7 @@ describe("computeLogbookCause", () => {
const cause = computeLogbookCause(
hass,
entry({ context_user_id: "person_1" }),
{ person_1: "Alice" },
{ person_1: "Paul" },
new Set(["cloud_user"])
);
expect(cause?.type).toBe("user");
@@ -405,144 +403,3 @@ describe("computeLogbookItem", () => {
expect(model.value).toEqual({ text: "Ran", type: "state" });
});
});
describe("isSameLogbookEntry", () => {
const a = entry({
when: 1.234567,
entity_id: "light.x",
state: "on",
name: "Light",
});
it("matches an identical entry", () => {
expect(isSameLogbookEntry(a, { ...a })).toBe(true);
});
it("rejects an entry differing by any field", () => {
expect(isSameLogbookEntry(a, { ...a, when: 1.234568 })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, entity_id: "light.y" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, state: "off" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, name: "Other" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, message: "turned on" })).toBe(false);
});
it("matches entity-less entries on name/message/when", () => {
const b = entry({ when: 2, name: "HACS", message: "2 updates available" });
expect(isSameLogbookEntry(b, { ...b })).toBe(true);
expect(isSameLogbookEntry(b, { ...b, message: "other" })).toBe(false);
});
});
describe("computeTraceLink", () => {
const traceContexts = {
ctx_1: { run_id: "run_9", domain: "automation", item_id: "auto_1" },
};
it("builds the trace URL for a known context", () => {
expect(computeTraceLink(traceContexts, "ctx_1")).toBe(
"/config/automation/trace/auto_1?run_id=run_9"
);
});
it("returns undefined for an unknown or missing context", () => {
expect(computeTraceLink(traceContexts, "ctx_2")).toBeUndefined();
expect(computeTraceLink(traceContexts, undefined)).toBeUndefined();
expect(computeTraceLink({}, "ctx_1")).toBeUndefined();
});
});
describe("computeLogbookItem cause", () => {
const hass = baseHass({ localize: (() => "") as HomeAssistant["localize"] });
const users = { user_1: "Alice" };
it("prefers the automation over the user who ran it", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "automation_triggered",
context_entity_id: "automation.wake_up",
context_name: "Wake up",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("automation");
expect(model.cause?.name).toBe("Wake up");
});
it("keeps the user for a direct action call", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("user");
expect(model.cause?.name).toBe("Alice");
});
it("falls back to the context cause without a user", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_event_type: "automation_triggered",
context_name: "Wake up",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("automation");
});
it("leaves the cause empty without any context", () => {
const model = computeLogbookItem(
hass,
entry({ entity_id: "light.ceiling", state: "on" }),
{ userIdToName: users }
);
expect(model.cause).toBeUndefined();
});
});
describe("computeLogbookItem run rows", () => {
it("uses the run label instead of the raw backend message", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
});
const model = computeLogbookItem(
hass,
entry({
entity_id: "automation.wake_up",
domain: "automation",
name: "Wake up",
message: "triggered",
}),
{}
);
expect(model.value).toEqual({
text: "ui.components.logbook.automation_triggered",
type: "state",
});
});
it("keeps the custom message of a domain-only entry (logbook.log)", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
});
const model = computeLogbookItem(
hass,
entry({ domain: "script", name: "Backup", message: "Backup finished" }),
{}
);
expect(model.value).toEqual({ text: "Backup finished", type: "message" });
});
});
@@ -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: [],

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