mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-16 11:27:54 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4e5279a5 | ||
|
|
848496b09e | ||
|
|
2c2eef7942 | ||
|
|
4e3cc4c705 | ||
|
|
8f96d2c6e4 | ||
|
|
2b9347fd72 | ||
|
|
12f54835f0 | ||
|
|
22ca986cce | ||
|
|
7f8bf69424 | ||
|
|
92224411e1 | ||
|
|
b52d58eccb | ||
|
|
a9cc47888a | ||
|
|
ea0ceecbfc | ||
|
|
5f007a1575 | ||
|
|
f360a22927 | ||
|
|
a67111e41f | ||
|
|
4b7d3a7e4f | ||
|
|
88be7adafa | ||
|
|
91a6d737b3 | ||
|
|
22c3a6fe67 | ||
|
|
bcc799970a | ||
|
|
31d4a37c15 |
@@ -11,6 +11,9 @@ inputs:
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
rspack-cache:
|
||||
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,3 +24,4 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
RSPACK_CACHE: ${{ inputs.rspack-cache }}
|
||||
|
||||
@@ -97,10 +97,12 @@ jobs:
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -111,12 +113,26 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
# nightly.yaml). rspack itself decides what is still valid (version +
|
||||
# buildDependencies + node_modules snapshot), so the GHA key just restores
|
||||
# the latest nightly cache; no fingerprint, and no save step (CI never
|
||||
# writes the shared cache).
|
||||
- name: Restore rspack cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -132,3 +148,54 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -58,9 +58,24 @@ jobs:
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
# The nightly writes the rspack persistent cache; CI reads it read-only
|
||||
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
|
||||
# + node_modules snapshot), so the cache rolls forward daily and a single
|
||||
# dependency bump keeps most of it warm instead of dropping the lineage.
|
||||
- name: Restore rspack cache
|
||||
id: rspack-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
RSPACK_CACHE: readwrite
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -69,7 +84,7 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
# Not gated on the restore step: a transient restore failure (it is
|
||||
# Not gated on the restore steps: a transient restore failure (they are
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
@@ -79,6 +94,14 @@ jobs:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Save rspack cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
gallery({ isProdBuild, latestBuild }) {
|
||||
gallery({ isProdBuild, latestBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "gallery" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -287,6 +287,7 @@ module.exports.config = {
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isTestBuild,
|
||||
defineOverlay: {
|
||||
__DEMO__: true,
|
||||
},
|
||||
|
||||
@@ -252,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
|
||||
createGalleryConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const { existsSync } = require("fs");
|
||||
const fs = require("fs");
|
||||
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
// Build-toolchain packages whose version changes the emitted bytes but which
|
||||
// are loader/compiler machinery, not modules in the build graph — so rspack's
|
||||
// node_modules snapshot cannot see them. Their versions are folded into the
|
||||
// persistent cache `version` so a toolchain upgrade invalidates the cache,
|
||||
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
|
||||
const TOOLCHAIN_PACKAGES = [
|
||||
"@rspack/core",
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
"@babel/runtime",
|
||||
"babel-loader",
|
||||
"core-js",
|
||||
"terser",
|
||||
"terser-webpack-plugin",
|
||||
"browserslist",
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
// absolute paths — rspack compares buildDependencies by path, which breaks a
|
||||
// cache reused on another machine/checkout (a different workspace path).
|
||||
const CONFIG_FILES = [
|
||||
__filename,
|
||||
path.join(__dirname, "bundle.cjs"),
|
||||
path.join(__dirname, "minify-template-literals-loader.cjs"),
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
.createHash("sha256")
|
||||
.update(parts.join("\n"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
class LogStartCompilePlugin {
|
||||
ignoredFirst = false;
|
||||
|
||||
@@ -376,6 +433,33 @@ const createRspackConfig = ({
|
||||
])
|
||||
),
|
||||
},
|
||||
// Persistent filesystem cache for production builds, opt-in per environment
|
||||
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
|
||||
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
|
||||
// tests) = no cache.
|
||||
...(isProdBuild && process.env.RSPACK_CACHE
|
||||
? {
|
||||
cache: {
|
||||
type: "persistent",
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files). Everything is path-independent, so the cache stays
|
||||
// valid when reused on another machine/checkout. Runtime deps are
|
||||
// deliberately absent — rspack's node_modules snapshot invalidates
|
||||
// their modules per-package, so a single unrelated bump keeps the
|
||||
// rest warm. buildDependencies is intentionally not used: rspack
|
||||
// compares it by absolute path, which breaks cross-machine reuse.
|
||||
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.resolve(paths.root_dir, ".rspack-cache"),
|
||||
},
|
||||
// CI reads the nightly-written cache but must not modify it.
|
||||
readonly: process.env.RSPACK_CACHE === "readonly",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
@@ -405,8 +489,10 @@ const createDemoConfig = ({
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
|
||||
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
@@ -14,6 +14,7 @@ const baseDevice = {
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
export const getLogbookDataFromServer = (
|
||||
const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
|
||||
@@ -2,12 +2,87 @@ import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type {
|
||||
ChildDeviceRegistryEntry,
|
||||
DeviceRegistryEntry,
|
||||
DeviceRegistryListEntry,
|
||||
} from "./device/device_registry";
|
||||
|
||||
// A full device carries fields that stripped children never do; use one of
|
||||
// those as the discriminant. This keeps "is a stripped child" decoupled from
|
||||
// "has a parent", so a hypothetical full-featured sub-device would still be
|
||||
// treated as a complete entry. We key off `connections` rather than
|
||||
// `config_entries` because the latter is a deprecated compatibility field core
|
||||
// plans to drop; `connections` is present on every full device and never on a
|
||||
// stripped child.
|
||||
const isChildEntry = (
|
||||
entry: DeviceRegistryListEntry
|
||||
): entry is ChildDeviceRegistryEntry => !("connections" in entry);
|
||||
|
||||
/**
|
||||
* Resolve the mixed device list from `config/device_registry/list` into a flat
|
||||
* list of complete {@link DeviceRegistryEntry} objects.
|
||||
*
|
||||
* Children are stripped over the wire and inherit the rest from their parent:
|
||||
* - config-entry association comes from the child's own `config_entry_id`, so
|
||||
* children still show up under their integration;
|
||||
* - hardware/display fields (manufacturer, model, versions, ...) are inherited
|
||||
* from the parent, since a child is a logical part of the same hardware;
|
||||
* - identity fields (`connections`, `via_device_id`) are NOT inherited — a
|
||||
* child is not the parent and has no connections of its own.
|
||||
*
|
||||
* Nesting is a single level (core rejects a child as another child's parent),
|
||||
* so no recursion is needed.
|
||||
*/
|
||||
export const resolveChildDevices = (
|
||||
entries: DeviceRegistryListEntry[]
|
||||
): DeviceRegistryEntry[] => {
|
||||
const parents = new Map<string, DeviceRegistryEntry>();
|
||||
for (const entry of entries) {
|
||||
if (!isChildEntry(entry)) {
|
||||
parents.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map((entry) => {
|
||||
if (!isChildEntry(entry)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const parent = parents.get(entry.parent_device_id);
|
||||
|
||||
return {
|
||||
// Structural fields derived from the child's own config entry.
|
||||
config_entries: [entry.config_entry_id],
|
||||
config_entries_subentries: {
|
||||
[entry.config_entry_id]: [entry.config_subentry_id],
|
||||
},
|
||||
primary_config_entry: entry.config_entry_id,
|
||||
// Hardware/display fields inherited from the parent.
|
||||
manufacturer: parent?.manufacturer ?? null,
|
||||
model: parent?.model ?? null,
|
||||
model_id: parent?.model_id ?? null,
|
||||
sw_version: parent?.sw_version ?? null,
|
||||
hw_version: parent?.hw_version ?? null,
|
||||
serial_number: parent?.serial_number ?? null,
|
||||
entry_type: parent?.entry_type ?? null,
|
||||
configuration_url: parent?.configuration_url ?? null,
|
||||
// Identity fields — a child has none of its own.
|
||||
connections: [],
|
||||
via_device_id: null,
|
||||
// The child's own fields (id, name, area_id, labels, identifiers,
|
||||
// parent_device_id, ...) win over everything above.
|
||||
...entry,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchDeviceRegistry = (conn: Connection) =>
|
||||
conn.sendMessagePromise<DeviceRegistryEntry[]>({
|
||||
type: "config/device_registry/list",
|
||||
});
|
||||
conn
|
||||
.sendMessagePromise<DeviceRegistryListEntry[]>({
|
||||
type: "config/device_registry/list",
|
||||
})
|
||||
.then(resolveChildDevices);
|
||||
|
||||
const subscribeDeviceRegistryUpdates = (
|
||||
conn: Connection,
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
@@ -60,6 +60,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { isIosApp } from "../../util/is_ios";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
import "../restart/automation-restart-status";
|
||||
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
|
||||
import { showVoiceCommandDialog } from "../voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import {
|
||||
@@ -799,9 +800,9 @@ export class QuickBar extends LitElement {
|
||||
title: this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_title`
|
||||
),
|
||||
text: this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_description`
|
||||
),
|
||||
text: html`${this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_description`
|
||||
)}<br /><br /><automation-restart-status></automation-restart-status>`,
|
||||
confirmText: this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_action`
|
||||
),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
import {
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
|
||||
const ENTITY_NAME_FORMAT: EntityNameItem[] = [
|
||||
{ type: "entity" },
|
||||
{ type: "area" },
|
||||
] as const;
|
||||
const ENTITY_NAME_OPTIONS = { separator: STRINGS_SEPARATOR_DOT } as const;
|
||||
|
||||
@customElement("automation-restart-status")
|
||||
class AutomationRestartStatus extends LitElement {
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
protected render() {
|
||||
const automations = Object.values(this._states).filter((s) => {
|
||||
const domain = computeDomain(s.entity_id);
|
||||
return (
|
||||
(domain === "script" || domain === "automation") && s.attributes.current
|
||||
);
|
||||
});
|
||||
|
||||
return automations.length
|
||||
? html`${this._i18n.localize("ui.dialogs.restart.interrupt_automations")}
|
||||
<ul>
|
||||
${automations.map((a) => html`<li>${this._formatters.formatEntityName(a, ENTITY_NAME_FORMAT, ENTITY_NAME_OPTIONS)}</li>`)}
|
||||
</ul>`
|
||||
: html`${this._i18n.localize("ui.dialogs.restart.no_interrupt_automations")}`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"automation-restart-status": AutomationRestartStatus;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
showConfirmationDialog,
|
||||
} from "../generic/show-dialog-box";
|
||||
import { showRestartWaitDialog } from "./show-dialog-restart";
|
||||
import "./automation-restart-status";
|
||||
|
||||
@customElement("dialog-restart")
|
||||
class DialogRestart extends LitElement {
|
||||
@@ -357,12 +358,12 @@ class DialogRestart extends LitElement {
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`),
|
||||
text: html`${this.hass.localize(
|
||||
`ui.dialogs.restart.${action}.confirm_description`
|
||||
)}${
|
||||
backupProgressMessage
|
||||
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
|
||||
: nothing
|
||||
}`,
|
||||
`ui.dialogs.restart.${action}.confirm_description`
|
||||
)}${
|
||||
backupProgressMessage
|
||||
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
|
||||
: nothing
|
||||
} <br /><br /><automation-restart-status></automation-restart-status>`,
|
||||
confirmText: this.hass.localize(
|
||||
`ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
|
||||
),
|
||||
|
||||
@@ -6,9 +6,13 @@ import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-analytics";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-spinner";
|
||||
import "../components/ha-svg-icon";
|
||||
import type { Analytics } from "../data/analytics";
|
||||
import { setAnalyticsPreferences } from "../data/analytics";
|
||||
import {
|
||||
getAnalyticsDetails,
|
||||
setAnalyticsPreferences,
|
||||
} from "../data/analytics";
|
||||
import { onboardAnalyticsStep } from "../data/onboarding";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
@@ -22,9 +26,11 @@ class OnboardingAnalytics extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _analyticsDetails: Analytics = {
|
||||
preferences: {},
|
||||
};
|
||||
// Undefined while we are still waiting for the analytics integration to be
|
||||
// set up (Home Assistant may still be starting up during onboarding).
|
||||
@state() private _analyticsDetails?: Analytics;
|
||||
|
||||
private _retryTimeout?: number;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
@@ -40,13 +46,26 @@ class OnboardingAnalytics extends LitElement {
|
||||
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
</a>
|
||||
</p>
|
||||
<ha-analytics
|
||||
translation_key_panel="page-onboarding"
|
||||
@analytics-preferences-changed=${this._preferencesChanged}
|
||||
.localize=${this.localize}
|
||||
.analytics=${this._analyticsDetails}
|
||||
>
|
||||
</ha-analytics>
|
||||
${
|
||||
this._analyticsDetails
|
||||
? html`
|
||||
<ha-analytics
|
||||
translation_key_panel="page-onboarding"
|
||||
@analytics-preferences-changed=${this._preferencesChanged}
|
||||
.localize=${this.localize}
|
||||
.analytics=${this._analyticsDetails}
|
||||
>
|
||||
</ha-analytics>
|
||||
`
|
||||
: html`
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
<p>
|
||||
${this.localize("ui.panel.page-onboarding.analytics.waiting")}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : ""}
|
||||
<div class="footer">
|
||||
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
|
||||
@@ -63,6 +82,35 @@ class OnboardingAnalytics extends LitElement {
|
||||
this._save(ev);
|
||||
}
|
||||
});
|
||||
this._loadAnalyticsDetails();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (this._retryTimeout) {
|
||||
clearTimeout(this._retryTimeout);
|
||||
this._retryTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadAnalyticsDetails(): Promise<void> {
|
||||
try {
|
||||
// The analytics integration registers its WebSocket commands during
|
||||
// setup, but only stores its data once the config entry is set up. On a
|
||||
// fresh install we can reach this step before that happened, so keep
|
||||
// retrying until it is ready instead of failing on save.
|
||||
this._analyticsDetails = await getAnalyticsDetails(this.hass);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
this._retryTimeout = window.setTimeout(
|
||||
() => this._loadAnalyticsDetails(),
|
||||
1000
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _preferencesChanged(
|
||||
@@ -76,6 +124,9 @@ class OnboardingAnalytics extends LitElement {
|
||||
|
||||
private async _save(ev) {
|
||||
ev.preventDefault();
|
||||
if (!this._analyticsDetails) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setAnalyticsPreferences(
|
||||
this.hass,
|
||||
@@ -98,6 +149,13 @@ class OnboardingAnalytics extends LitElement {
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+49
-14
@@ -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 {
|
||||
|
||||
+23
-4
@@ -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,
|
||||
|
||||
+5
-3
@@ -240,7 +240,7 @@ export class BluetoothNetworkVisualization extends LitElement {
|
||||
const scannerDevice = this._sourceDevices[scanner.source] as
|
||||
DeviceRegistryEntry | undefined;
|
||||
const area = scannerDevice
|
||||
? getDeviceArea(scannerDevice, this.hass.areas)
|
||||
? getDeviceArea(scannerDevice, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
nodes.push({
|
||||
id: scanner.source,
|
||||
@@ -282,7 +282,7 @@ export class BluetoothNetworkVisualization extends LitElement {
|
||||
const device = this._sourceDevices[node.address] as
|
||||
DeviceRegistryEntry | undefined;
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas)
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
nodes.push({
|
||||
id: node.address,
|
||||
@@ -350,7 +350,9 @@ export class BluetoothNetworkVisualization extends LitElement {
|
||||
const name = this._getBluetoothDeviceName(address);
|
||||
const btDevice = this._data.find((d) => d.address === address);
|
||||
const device = this._sourceDevices[address];
|
||||
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const areaLine = area
|
||||
? html`<br /><b
|
||||
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
|
||||
|
||||
@@ -64,7 +64,9 @@ export function createZHANetworkChartData(
|
||||
|
||||
const haDevice = hass.devices[device.device_reg_id] as
|
||||
DeviceRegistryEntry | undefined;
|
||||
const area = haDevice ? getDeviceArea(haDevice, hass.areas) : undefined;
|
||||
const area = haDevice
|
||||
? getDeviceArea(haDevice, hass.areas, hass.devices)
|
||||
: undefined;
|
||||
// Create node
|
||||
nodes.push({
|
||||
id: device.ieee,
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
const haDevice = this.hass.devices[device.device_reg_id] as
|
||||
DeviceRegistryEntry | undefined;
|
||||
const area = haDevice
|
||||
? getDeviceArea(haDevice, this.hass.areas)
|
||||
? getDeviceArea(haDevice, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
return html`<b>IEEE: </b>${device.ieee}<br /><b
|
||||
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
|
||||
|
||||
+5
-1
@@ -177,7 +177,11 @@ class DialogZWaveJSRebuildNetworkRoutesDetail extends DialogMixin<ZWaveJSRebuild
|
||||
) ||
|
||||
this._i18n.localize("ui.components.device-picker.unnamed_device");
|
||||
|
||||
const area = getDeviceArea(device, this._registries.areas);
|
||||
const area = getDeviceArea(
|
||||
device,
|
||||
this._registries.areas,
|
||||
this._registries.devices
|
||||
);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
|
||||
+4
-2
@@ -183,7 +183,9 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
const { id, name } = data as any;
|
||||
const device = this._devices[id] as DeviceRegistryEntry | undefined;
|
||||
const nodeStatus = this._nodeStatuses[id];
|
||||
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
return html`<ha-chart-tooltip-marker
|
||||
.color=${String((params as CallbackDataParams).color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
@@ -295,7 +297,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
const device = this._devices[node.node_id] as
|
||||
DeviceRegistryEntry | undefined;
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas)
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
nodes.push({
|
||||
id: String(node.node_id),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { resolveChildDevices } from "../../src/data/ws-device_registry";
|
||||
import type {
|
||||
ChildDeviceRegistryEntry,
|
||||
DeviceRegistryEntry,
|
||||
} from "../../src/data/device/device_registry";
|
||||
|
||||
const parent: DeviceRegistryEntry = {
|
||||
id: "parent",
|
||||
config_entries: ["entry-1"],
|
||||
config_entries_subentries: { "entry-1": [null] },
|
||||
connections: [["mac", "aa:bb:cc:dd:ee:ff"]],
|
||||
identifiers: [["hue", "strip-1"]],
|
||||
manufacturer: "Acme",
|
||||
model: "Power Strip",
|
||||
model_id: "PS-1",
|
||||
name: "Power strip",
|
||||
labels: ["strip"],
|
||||
sw_version: "1.0",
|
||||
hw_version: "2.0",
|
||||
serial_number: "SN-1",
|
||||
via_device_id: "bridge",
|
||||
area_id: "living_room",
|
||||
name_by_user: null,
|
||||
entry_type: null,
|
||||
disabled_by: null,
|
||||
configuration_url: "http://strip.local",
|
||||
primary_config_entry: "entry-1",
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
const child: ChildDeviceRegistryEntry = {
|
||||
id: "child",
|
||||
config_entry_id: "entry-1",
|
||||
config_subentry_id: "sub-1",
|
||||
identifiers: [["hue", "outlet-1"]],
|
||||
name: "Outlet 1",
|
||||
name_by_user: "Coffee machine",
|
||||
labels: ["outlet"],
|
||||
area_id: "kitchen",
|
||||
disabled_by: null,
|
||||
parent_device_id: "parent",
|
||||
created_at: 5,
|
||||
modified_at: 6,
|
||||
};
|
||||
|
||||
describe("resolveChildDevices", () => {
|
||||
it("leaves full devices untouched", () => {
|
||||
const [resolved] = resolveChildDevices([parent]);
|
||||
assert.strictEqual(resolved, parent);
|
||||
});
|
||||
|
||||
it("resolves a child into a complete device entry", () => {
|
||||
const result = resolveChildDevices([parent, child]);
|
||||
const resolved = result.find((d) => d.id === "child")!;
|
||||
|
||||
// Config-entry association comes from the child's own config entry.
|
||||
assert.deepEqual(resolved.config_entries, ["entry-1"]);
|
||||
assert.deepEqual(resolved.config_entries_subentries, {
|
||||
"entry-1": ["sub-1"],
|
||||
});
|
||||
assert.strictEqual(resolved.primary_config_entry, "entry-1");
|
||||
|
||||
// Hardware/display fields are inherited from the parent.
|
||||
assert.strictEqual(resolved.manufacturer, "Acme");
|
||||
assert.strictEqual(resolved.model, "Power Strip");
|
||||
assert.strictEqual(resolved.model_id, "PS-1");
|
||||
assert.strictEqual(resolved.sw_version, "1.0");
|
||||
assert.strictEqual(resolved.hw_version, "2.0");
|
||||
assert.strictEqual(resolved.serial_number, "SN-1");
|
||||
assert.strictEqual(resolved.configuration_url, "http://strip.local");
|
||||
assert.strictEqual(resolved.entry_type, null);
|
||||
|
||||
// Identity fields are NOT inherited — a child is not the parent.
|
||||
assert.deepEqual(resolved.connections, []);
|
||||
assert.strictEqual(resolved.via_device_id, null);
|
||||
|
||||
// The child's own fields win.
|
||||
assert.strictEqual(resolved.id, "child");
|
||||
assert.strictEqual(resolved.name, "Outlet 1");
|
||||
assert.strictEqual(resolved.name_by_user, "Coffee machine");
|
||||
assert.strictEqual(resolved.area_id, "kitchen");
|
||||
assert.deepEqual(resolved.labels, ["outlet"]);
|
||||
assert.deepEqual(resolved.identifiers, [["hue", "outlet-1"]]);
|
||||
assert.strictEqual(resolved.parent_device_id, "parent");
|
||||
assert.strictEqual(resolved.created_at, 5);
|
||||
assert.strictEqual(resolved.modified_at, 6);
|
||||
});
|
||||
|
||||
it("falls back to null display fields when the parent is missing", () => {
|
||||
const [resolved] = resolveChildDevices([child]);
|
||||
|
||||
assert.deepEqual(resolved.config_entries, ["entry-1"]);
|
||||
assert.strictEqual(resolved.manufacturer, null);
|
||||
assert.strictEqual(resolved.model, null);
|
||||
assert.deepEqual(resolved.connections, []);
|
||||
assert.strictEqual(resolved.via_device_id, null);
|
||||
assert.strictEqual(resolved.parent_device_id, "parent");
|
||||
});
|
||||
|
||||
it("preserves ordering of the mixed list", () => {
|
||||
const result = resolveChildDevices([child, parent]);
|
||||
assert.deepEqual(
|
||||
result.map((d) => d.id),
|
||||
["child", "parent"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ const subscriptionResults: Record<string, unknown> = {
|
||||
};
|
||||
|
||||
const commandResults: Record<string, unknown> = {
|
||||
analytics: { preferences: {} },
|
||||
"analytics/preferences": {},
|
||||
"auth/current_user": currentUser,
|
||||
"brands/access_token": { token: "brands-token" },
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user