mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-29 17:57:33 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b90c9a628 | |||
| f3848df19d | |||
| e2a91c358d | |||
| 8793cb58b9 | |||
| 2442bf662a | |||
| e42be9f4d6 | |||
| a096f8383d | |||
| a3e59d64fc | |||
| e90e8169cd | |||
| 959a798b5c | |||
| 9c117d446e | |||
| 11913292b1 | |||
| 52f2b5abed | |||
| e658113ef4 | |||
| c35e316ef7 | |||
| 946c40d8a3 | |||
| 7ca7c08090 | |||
| 73865161e7 | |||
| f9d1059ff6 | |||
| 2dabc28e7f | |||
| fd70fcef2f | |||
| 9bf46415f1 | |||
| c552af4c12 | |||
| c573669786 | |||
| 4e1ccab159 | |||
| 347d63bce9 | |||
| a1d7b31732 | |||
| 4d61df7ed7 | |||
| 991dda70fc | |||
| 58f5480ca3 | |||
| 1f20bc0749 | |||
| 0846cb3be3 | |||
| 33afb77367 | |||
| b2f85e2595 | |||
| 09956a7d9c | |||
| 8507e222f8 | |||
| 5737480398 | |||
| 44163b9ccb | |||
| e79cd0c5b2 | |||
| 52379b39e0 | |||
| 656e1bea8e | |||
| 4ef3ed2f02 | |||
| fbad0ba885 | |||
| c892691344 | |||
| 811b7c7d98 | |||
| 91dee86697 | |||
| 9877377cb9 | |||
| 7a1c8c556f | |||
| 2c3d8eb230 | |||
| 67489affe7 | |||
| 7fcbd8e245 | |||
| 12a88231f3 | |||
| f66619fae6 | |||
| 43ff58010a | |||
| c391d571d7 | |||
| 18e15f8a99 | |||
| dfdd55b649 | |||
| bed98776c3 | |||
| ad37f1bb58 | |||
| 2a00b0d0ec | |||
| 20efc35da3 | |||
| ac71b4c400 | |||
| b85422e652 | |||
| 4ff69aab8f | |||
| ebb15d1118 |
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: ha-frontend-demo
|
||||
description: Home Assistant standalone demo structure, configurations, URL navigation, mocked APIs, shared demo stubs used by gallery, and verification. Use when changing files under demo/, changing gallery consumers of demo/src/stubs/, or running and validating the demo.
|
||||
---
|
||||
|
||||
# HA Frontend Demo
|
||||
|
||||
Use this skill for standalone demo work and changes to shared demo stubs consumed by the gallery. Follow the persistent repository guidance in `AGENTS.md` and load matching specialist skills alongside this skill, especially `ha-frontend-testing` when running or validating the demo.
|
||||
|
||||
## Purpose
|
||||
|
||||
The demo is the full Home Assistant frontend running against a mocked backend and is published at <https://demo.home-assistant.io>. It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
|
||||
|
||||
## Running The Demo
|
||||
|
||||
Run commands from the repository root:
|
||||
|
||||
```bash
|
||||
yarn dev:demo # Development server on http://localhost:8090
|
||||
yarn dev:demo --background # Detached; also supports --status/--stop/--logs
|
||||
```
|
||||
|
||||
Use the E2E workflows documented in `ha-frontend-testing` when validating the demo.
|
||||
|
||||
## Opening A Specific Demo
|
||||
|
||||
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, for example `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `demo/src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default, which is the first entry.
|
||||
|
||||
## Opening A Specific Page
|
||||
|
||||
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
|
||||
|
||||
```text
|
||||
http://localhost:8090/?demo=<slug>#/<path>
|
||||
```
|
||||
|
||||
Useful paths:
|
||||
|
||||
- `/lovelace/0`: The selected demo's dashboard, also the default when no hash is given.
|
||||
- `/energy`: The energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, and `/energy/now`.
|
||||
- `/map`, `/history`, `/todo`, `/config`: Other sidebar panels.
|
||||
|
||||
For example, the water tab of the energy dashboard of the second demo is:
|
||||
|
||||
```text
|
||||
http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `demo/src/ha-demo.ts`: Root element that sets up all backend mocks.
|
||||
- `demo/src/configs/<slug>/`: One directory per demo configuration, containing entities, dashboard, and theme data.
|
||||
- `demo/src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
|
||||
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
|
||||
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
|
||||
|
||||
## Shared Gallery Stubs
|
||||
|
||||
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
|
||||
|
||||
The two consumers use a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object. A stub that mutates `hass.config` in place is therefore visible to the page. `demo/src/ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can look correct in the demo while quietly breaking a gallery page.
|
||||
|
||||
## Verification
|
||||
|
||||
Load `ha-frontend-testing` and use its demo and gallery workflows. Validate both consumers when changing shared stubs. Documentation-only changes do not require code tests unless examples or commands change.
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
@@ -47,6 +45,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
|
||||
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 561513,
|
||||
"core": 54473,
|
||||
"authorize": 544272,
|
||||
"onboarding": 647136
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 790323,
|
||||
"core": 237208,
|
||||
"authorize": 765464,
|
||||
"onboarding": 918679
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,42 @@
|
||||
const remapping = require("@ampproject/remapping");
|
||||
|
||||
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
|
||||
let minifyPromise;
|
||||
const getMinifier = () => {
|
||||
if (!minifyPromise) {
|
||||
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
|
||||
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
|
||||
const loaderInitPromises = new Map();
|
||||
const initLoader = (browserslistEnv) => {
|
||||
if (!loaderInitPromises.has(browserslistEnv)) {
|
||||
loaderInitPromises.set(
|
||||
browserslistEnv,
|
||||
Promise.all([
|
||||
import("minify-literals"),
|
||||
import("browserslist"),
|
||||
import("lightningcss"),
|
||||
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
|
||||
const browserslist = browserslistModule.default;
|
||||
const { browserslistToTargets } = lightningcssModule;
|
||||
const rawTargets = browserslist(null, { env: browserslistEnv });
|
||||
if (!rawTargets.length) {
|
||||
throw new Error(
|
||||
`No browsers resolved for browserslist environment "${browserslistEnv}"`
|
||||
);
|
||||
}
|
||||
const lightningcssTargets = browserslistToTargets(rawTargets);
|
||||
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return minifyPromise;
|
||||
return loaderInitPromises.get(browserslistEnv);
|
||||
};
|
||||
|
||||
// HTML options mirror the previous babel-plugin-template-html-minifier config
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
// css`` templates and inline <style> is handled by minify-literals'
|
||||
// lightningcss. We pass in the targets from browserslist so lightningcss
|
||||
// can minify CSS appropriately for the build environment.
|
||||
//
|
||||
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
|
||||
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
|
||||
@@ -40,11 +64,16 @@ const htmlOptions = {
|
||||
|
||||
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const callback = this.async();
|
||||
getMinifier()
|
||||
.then((minifyHTMLLiterals) =>
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@@ -96,6 +96,11 @@ const createRspackConfig = ({
|
||||
__dirname,
|
||||
"minify-template-literals-loader.cjs"
|
||||
),
|
||||
options: {
|
||||
browserslistEnv: latestBuild
|
||||
? "modern"
|
||||
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
|
||||
},
|
||||
},
|
||||
!latestBuild &&
|
||||
info.resource.startsWith(
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# Demo Agent Instructions
|
||||
|
||||
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
|
||||
|
||||
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
|
||||
|
||||
## Running the demo
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
yarn dev:demo # dev server on http://localhost:8090
|
||||
yarn dev:demo --background # detached; also supports --status/--stop/--logs
|
||||
```
|
||||
|
||||
## Opening a specific demo
|
||||
|
||||
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
|
||||
|
||||
## Opening a specific page
|
||||
|
||||
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
|
||||
|
||||
```
|
||||
http://localhost:8090/?demo=<slug>#/<path>
|
||||
```
|
||||
|
||||
Useful paths:
|
||||
|
||||
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
|
||||
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
|
||||
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
|
||||
|
||||
Example — the water tab of the energy dashboard of the second demo:
|
||||
|
||||
```
|
||||
http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
|
||||
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
|
||||
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
|
||||
- `src/stubs/`: Mocked WebSocket/REST APIs.
|
||||
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
|
||||
|
||||
## The gallery imports these stubs too
|
||||
|
||||
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo.
|
||||
|
||||
```bash
|
||||
grep -rn "stubs/<name>" demo/src gallery/src
|
||||
```
|
||||
|
||||
The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
+3
-3
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.8.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
@@ -224,12 +224,12 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.8.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
"node": "24.18.1"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.0"
|
||||
version = "20260729.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { EntityIdPart } from "../../data/entity_id_format";
|
||||
import { slugify } from "../string/slugify";
|
||||
|
||||
export const computeEntityIdFormatExample = (
|
||||
format: EntityIdPart[],
|
||||
examples: Record<EntityIdPart, string>
|
||||
): string => {
|
||||
const parts = format
|
||||
.map((item) => examples[item])
|
||||
.filter(Boolean)
|
||||
.map((part) => slugify(part, "_"))
|
||||
.filter(Boolean);
|
||||
|
||||
return parts.join("_") || "unknown";
|
||||
};
|
||||
@@ -1,8 +1,29 @@
|
||||
let supported: boolean | undefined;
|
||||
|
||||
const detect = (): boolean => {
|
||||
if (
|
||||
!globalThis.ElementInternals ||
|
||||
!globalThis.HTMLElement?.prototype.attachInternals
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Native internals keep their WebIDL brand even when `attachInternals` is
|
||||
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
|
||||
// broke the previous `[native code]` source check in the app bundle).
|
||||
// `element-internals-polyfill` swaps in a plain class, which has no brand,
|
||||
// and must not count as native: login on legacy browsers relies on
|
||||
// validation being skipped there (#51338).
|
||||
return (
|
||||
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
|
||||
"[object ElementInternals]"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates whether the current browser has native ElementInternals support.
|
||||
* Probed on first use so importing this module has no side effects.
|
||||
*/
|
||||
export const nativeElementInternalsSupported =
|
||||
Boolean(globalThis.ElementInternals) &&
|
||||
globalThis.HTMLElement?.prototype.attachInternals
|
||||
?.toString()
|
||||
.includes("[native code]");
|
||||
export const supportsNativeElementInternals = (): boolean => {
|
||||
supported ??= detect();
|
||||
return supported;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* Checks that a path resolves to the origin the frontend is served from, so it
|
||||
* is safe to use as a link target. Rejects URIs that carry their own scheme,
|
||||
* like `javascript:`, and URLs pointing at another origin. Resolves against the
|
||||
* main window, because that is where `navigate()` applies the path.
|
||||
*/
|
||||
const isSameOriginPath = (path: string): boolean => {
|
||||
try {
|
||||
const { origin } = mainWindow.location;
|
||||
return new URL(path, origin).origin === origin;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
|
||||
* paths that can be influenced by a URL parameter or by dashboard config before
|
||||
* they end up in an `href` or in `navigate()`.
|
||||
*/
|
||||
export const sanitizeNavigationPath = (
|
||||
path: string | null | undefined
|
||||
): string | undefined =>
|
||||
path != null && isSameOriginPath(path) ? path : undefined;
|
||||
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host([appearance="brand"]) ha-svg-icon {
|
||||
.progress ha-svg-icon {
|
||||
color: var(--white-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -27,6 +27,7 @@ export class HaInputChip extends InputChip {
|
||||
);
|
||||
--ha-input-chip-selected-container-opacity: 1;
|
||||
--md-input-chip-label-text-font: Roboto, sans-serif;
|
||||
--md-input-chip-label-text-weight: 400;
|
||||
}
|
||||
/** Set the size of mdc icons **/
|
||||
::slotted([slot="icon"]) {
|
||||
|
||||
@@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-dialog";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-button";
|
||||
@@ -23,11 +26,21 @@ export class DialogDeviceReplaced
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
private async _loadConfigEntries(): Promise<void> {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
configEntryLookup: Record<string, ConfigEntry> | undefined
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
const configEntry = device?.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
return {
|
||||
deviceId,
|
||||
name: device ? computeDeviceName(device) : deviceId,
|
||||
secondary: area ? computeAreaName(area) : undefined,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
domain: configEntry?.domain,
|
||||
domainName: configEntry
|
||||
? domainToName(this.hass.localize, configEntry.domain)
|
||||
: undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
).map(
|
||||
(item) => html`
|
||||
this.hass.areas,
|
||||
this._configEntryLookup
|
||||
).map((item) => {
|
||||
const supportingText = [
|
||||
item.area,
|
||||
item.domainName,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
return html`
|
||||
<ha-list-item-button .deviceId=${item.deviceId}>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
${
|
||||
item.domain
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span slot="headline">${item.name}</span>
|
||||
${
|
||||
item.secondary || item.isPrimary
|
||||
? html`<span slot="supporting-text">
|
||||
${[
|
||||
item.secondary,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ")}
|
||||
</span>`
|
||||
supportingText
|
||||
? html`<span slot="supporting-text"
|
||||
>${supportingText}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -132,6 +176,10 @@ export class DialogDeviceReplaced
|
||||
padding: 0 var(--ha-space-6) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+37
-10
@@ -1,6 +1,7 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, LitElement, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { blankBeforePercent } from "../common/translations/blank_before_percent";
|
||||
@@ -46,15 +47,32 @@ export class HaGauge extends LitElement {
|
||||
|
||||
@state() private _segment_label?: string = "";
|
||||
|
||||
@query(".text") private _textSvg?: SVGSVGElement;
|
||||
|
||||
@query(".value-text") private _valueText?: SVGTextElement;
|
||||
|
||||
private _sortedLevels?: LevelDefinition[];
|
||||
|
||||
private _rescaleOnConnect = false;
|
||||
// Set when the value text could not be measured because we have no layout box
|
||||
// yet, either disconnected or inside a hidden container.
|
||||
private _rescalePending = false;
|
||||
|
||||
// Measure again once we get a layout box, e.g. when a section hidden by a
|
||||
// visibility condition is revealed. Nothing else re-renders the gauge then.
|
||||
// @ts-ignore side-effect-only controller, its value is never read
|
||||
private _resizeController = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
if (this._rescalePending && entries[0]?.contentRect.width) {
|
||||
this._rescaleSvg();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._rescaleOnConnect) {
|
||||
if (this._rescalePending && this.hasUpdated) {
|
||||
this._rescaleSvg();
|
||||
this._rescaleOnConnect = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,15 +239,22 @@ export class HaGauge extends LitElement {
|
||||
// fit the text
|
||||
// That way it will auto-scale correctly
|
||||
|
||||
if (!this.isConnected) {
|
||||
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
|
||||
this._rescaleOnConnect = true;
|
||||
if (!this._textSvg || !this._valueText || !this.isConnected) {
|
||||
this._rescalePending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const svgRoot = this.shadowRoot!.querySelector(".text")!;
|
||||
const box = svgRoot.querySelector("text")!.getBBox()!;
|
||||
svgRoot.setAttribute(
|
||||
const box = this._valueText.getBBox();
|
||||
|
||||
// An empty box means we have no layout, so keep the last known good viewBox
|
||||
// and retry later. A viewBox with a 0 width or height would hide the label.
|
||||
if (!box.width || !box.height) {
|
||||
this._rescalePending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this._rescalePending = false;
|
||||
this._textSvg.setAttribute(
|
||||
"viewBox",
|
||||
`${box.x} ${box.y} ${box.width} ${box.height}`
|
||||
);
|
||||
@@ -248,6 +273,8 @@ export class HaGauge extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
/* a non replaced inline element never reports a size to a resize observer */
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { subscribeLabFeature } from "../data/labs";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
@@ -81,14 +82,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
|
||||
class="snowflake ${
|
||||
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
|
||||
}"
|
||||
style="
|
||||
left: ${flake.left}%;
|
||||
width: ${flake.size}px;
|
||||
height: ${flake.size}px;
|
||||
animation-duration: ${flake.duration}s;
|
||||
animation-delay: ${flake.delay}s;
|
||||
--rotation: ${flake.rotation}deg;
|
||||
"
|
||||
style=${styleMap({
|
||||
left: `${flake.left}%`,
|
||||
width: `${flake.size}px`,
|
||||
height: `${flake.size}px`,
|
||||
"animation-duration": `${flake.duration}s`,
|
||||
"animation-delay": `${flake.delay}s`,
|
||||
"--rotation": `${flake.rotation}deg`,
|
||||
})}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type LitElement, css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
|
||||
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
|
||||
import type { Constructor } from "../../types";
|
||||
|
||||
/**
|
||||
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
}
|
||||
|
||||
public checkValidity(): boolean {
|
||||
return nativeElementInternalsSupported
|
||||
return supportsNativeElementInternals()
|
||||
? (this._formControl?.checkValidity() ?? true)
|
||||
: true;
|
||||
}
|
||||
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
|
||||
protected _handleInput(): void {
|
||||
this.value = this._formControl?.value ?? undefined;
|
||||
if (this._invalid && this._formControl?.checkValidity()) {
|
||||
if (this._invalid && this.checkValidity()) {
|
||||
this._invalid = false;
|
||||
}
|
||||
}
|
||||
@@ -222,12 +222,16 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
|
||||
protected _handleBlur(): void {
|
||||
if (this.autoValidate) {
|
||||
this._invalid = !this._formControl?.checkValidity();
|
||||
this._invalid = !this.checkValidity();
|
||||
}
|
||||
}
|
||||
|
||||
protected _handleInvalid(): void {
|
||||
this._invalid = true;
|
||||
// Polyfilled internals dispatch `invalid` themselves, so only trust the
|
||||
// event when validity comes from the platform (#51338).
|
||||
if (supportsNativeElementInternals()) {
|
||||
this._invalid = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
padding: 10px;
|
||||
}
|
||||
.vertical ::slotted([slot="info"]) {
|
||||
width: 100%;
|
||||
|
||||
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
no-row-click
|
||||
></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-row-click
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
+13
-11
@@ -49,23 +49,25 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
|
||||
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device_id: {},
|
||||
dynamicGroups: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
|
||||
groups: {
|
||||
event: {},
|
||||
service: {},
|
||||
set_conversation_response: {},
|
||||
helpers: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.generic.label",
|
||||
generic: true,
|
||||
groups: {
|
||||
event: {},
|
||||
service: {},
|
||||
set_conversation_response: {},
|
||||
other: {},
|
||||
device_id: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.actions.groups.integrations.label",
|
||||
groups: {
|
||||
integrationGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -21,7 +21,6 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
helpers: {},
|
||||
template: {},
|
||||
trigger: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -35,9 +34,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
|
||||
"ui.panel.config.automation.editor.conditions.groups.integrations.label",
|
||||
groups: {
|
||||
customDynamicGroups: {},
|
||||
integrationGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -101,6 +101,23 @@ export const fetchDeviceCompositeSplits = (
|
||||
return request;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the devices that are linked to the given device because they share at
|
||||
* least one connection or identifier. These are separate devices (one per
|
||||
* config entry) that represent the same physical hardware, managed by
|
||||
* different integrations.
|
||||
*/
|
||||
export const fetchLinkedDevices = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
deviceId: string
|
||||
): Promise<string[]> =>
|
||||
hass
|
||||
.callWS<{ linked_devices: string[] }>({
|
||||
type: "config/device_registry/list_linked_devices",
|
||||
device_id: deviceId,
|
||||
})
|
||||
.then((result) => result.linked_devices);
|
||||
|
||||
export const fallbackDeviceName = (
|
||||
hass: HomeAssistant,
|
||||
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HomeAssistantApi } from "../../types";
|
||||
import type { EntityIdFormat } from "../entity_id_format";
|
||||
|
||||
export interface EntityRegistrySettings {
|
||||
entity_id_parts: EntityIdFormat | null;
|
||||
}
|
||||
|
||||
export const fetchEntityRegistrySettings = (
|
||||
api: HomeAssistantApi
|
||||
): Promise<EntityRegistrySettings> =>
|
||||
api.callWS<EntityRegistrySettings>({
|
||||
type: "config/entity_registry/settings/get",
|
||||
});
|
||||
|
||||
export const updateEntityRegistrySettings = (
|
||||
api: HomeAssistantApi,
|
||||
updates: Partial<EntityRegistrySettings>
|
||||
): Promise<EntityRegistrySettings> =>
|
||||
api.callWS<EntityRegistrySettings>({
|
||||
type: "config/entity_registry/settings/update",
|
||||
...updates,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type EntityIdPart = "area" | "device" | "entity" | "floor";
|
||||
|
||||
export type EntityIdFormat = EntityIdPart[];
|
||||
|
||||
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
];
|
||||
|
||||
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
|
||||
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
|
||||
@@ -76,15 +76,6 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
export const getLogbookEvents = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
entityIds?: string[],
|
||||
contextId?: string
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId);
|
||||
|
||||
const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
|
||||
+2
-3
@@ -35,7 +35,6 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
webhook: {},
|
||||
persistent_notification: {},
|
||||
helpers: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,9 +47,9 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.triggers.groups.custom_integrations.label",
|
||||
"ui.panel.config.automation.editor.triggers.groups.integrations.label",
|
||||
groups: {
|
||||
customDynamicGroups: {},
|
||||
integrationGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -29,16 +30,18 @@ class HassSubpage extends LitElement {
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
return html`
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || history.state?.root
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this.backPath
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
href=${this.backPath}
|
||||
href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -15,6 +15,7 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -164,17 +165,19 @@ export class HassTabsSubpage extends LitElement {
|
||||
this._narrow,
|
||||
this.localizeFunc || this.hass.localize
|
||||
);
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
return html`
|
||||
<div class="toolbar ${classMap({ narrow: this._narrow })}">
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!this.backPath && history.state?.root)
|
||||
this.mainPage || (!backPath && history.state?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this.backPath
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${this.backPath}
|
||||
.href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -183,12 +183,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
|
||||
|
||||
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
|
||||
|
||||
const DYNAMIC_KEYWORDS = [
|
||||
"dynamicGroups",
|
||||
"helpers",
|
||||
"other",
|
||||
"customDynamicGroups",
|
||||
];
|
||||
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "integrationGroups"];
|
||||
|
||||
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
|
||||
|
||||
@@ -197,7 +192,13 @@ const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
|
||||
// drills into its items, like selecting the matching group in the "by type" tab.
|
||||
const TIME_LOCATION_GROUPS = ["time", "sun"];
|
||||
|
||||
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
|
||||
type CollectionGroupType = "helper" | "dynamic" | "integration";
|
||||
|
||||
interface DomainClassificationOptions {
|
||||
type: AddAutomationElementDialogParams["type"];
|
||||
usedDomains?: Set<string>;
|
||||
activeSystemDomains?: Set<string>;
|
||||
}
|
||||
|
||||
@customElement("add-automation-element-dialog")
|
||||
class DialogAddAutomationElement
|
||||
@@ -501,7 +502,9 @@ class DialogAddAutomationElement
|
||||
);
|
||||
|
||||
private get _systemDomains() {
|
||||
if (!this._manifests) {
|
||||
// System domains are derived from trigger/condition descriptions, so
|
||||
// they don't apply to actions.
|
||||
if (!this._manifests || this._params?.type === "action") {
|
||||
return undefined;
|
||||
}
|
||||
const descriptions =
|
||||
@@ -1029,7 +1032,6 @@ class DialogAddAutomationElement
|
||||
this._params!.type,
|
||||
this._selectedGroup,
|
||||
this._selectedCollectionIndex ?? 0,
|
||||
this._domains,
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._manifests,
|
||||
@@ -1123,6 +1125,13 @@ class DialogAddAutomationElement
|
||||
|
||||
const exclusiveDomains = this._getExclusiveDomains(type);
|
||||
|
||||
const domainList =
|
||||
type === "trigger"
|
||||
? Object.keys(triggerDescriptions ?? {}).map(getTriggerDomain)
|
||||
: type === "condition"
|
||||
? Object.keys(conditionDescriptions ?? {}).map(getConditionDomain)
|
||||
: Object.keys(services ?? {});
|
||||
|
||||
collections.forEach((collection, index) => {
|
||||
let collectionGroups = Object.entries(collection.groups);
|
||||
const groups: AddAutomationElementListItem[] = [];
|
||||
@@ -1134,70 +1143,23 @@ class DialogAddAutomationElement
|
||||
if (collection.groups.helpers) {
|
||||
types.push("helper");
|
||||
}
|
||||
if (collection.groups.other) {
|
||||
types.push("other");
|
||||
}
|
||||
if (collection.groups.customDynamicGroups) {
|
||||
types.push("customDynamic");
|
||||
if (collection.groups.integrationGroups) {
|
||||
types.push("integration");
|
||||
}
|
||||
|
||||
if (
|
||||
type === "trigger" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
DYNAMIC_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
if (types.length) {
|
||||
groups.push(
|
||||
...this._triggerGroups(
|
||||
...this._dynamicDomainGroups(
|
||||
localize,
|
||||
triggerDescriptions,
|
||||
domainList,
|
||||
manifests,
|
||||
domains,
|
||||
types,
|
||||
exclusiveDomains
|
||||
)
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !DYNAMIC_KEYWORDS.includes(key)
|
||||
);
|
||||
} else if (
|
||||
type === "condition" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
DYNAMIC_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
...this._conditionGroups(
|
||||
localize,
|
||||
conditionDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
types,
|
||||
exclusiveDomains
|
||||
)
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !DYNAMIC_KEYWORDS.includes(key)
|
||||
);
|
||||
} else if (
|
||||
type === "action" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
DYNAMIC_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
...this._serviceGroups(
|
||||
localize,
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
collection.groups.dynamicGroups
|
||||
? undefined
|
||||
: collection.groups.helpers
|
||||
? "helper"
|
||||
: "other"
|
||||
{
|
||||
type,
|
||||
usedDomains: domains,
|
||||
activeSystemDomains: this._systemDomains?.active,
|
||||
exclusiveDomains,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1289,7 +1251,6 @@ class DialogAddAutomationElement
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
group: string,
|
||||
collectionIndex: number,
|
||||
domains: Set<string> | undefined,
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
manifests?: DomainManifestLookup,
|
||||
@@ -1339,144 +1300,69 @@ class DialogAddAutomationElement
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "action") {
|
||||
if (!this._selectedGroup) {
|
||||
result.unshift(
|
||||
...this._serviceGroups(
|
||||
localize,
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
} else if (this._selectedGroup === "helpers") {
|
||||
result.unshift(
|
||||
...this._serviceGroups(
|
||||
localize,
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
"helper"
|
||||
)
|
||||
);
|
||||
} else if (this._selectedGroup === "other") {
|
||||
result.unshift(
|
||||
...this._serviceGroups(
|
||||
localize,
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
"other"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _serviceGroups = (
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
type: "helper" | "other" | undefined
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!services || !manifests) {
|
||||
return [];
|
||||
}
|
||||
const result: AddAutomationElementListItem[] = [];
|
||||
Object.keys(services).forEach((domain) => {
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
if (
|
||||
(type === undefined &&
|
||||
(ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
domainUsed &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
|
||||
(type === "helper" && manifest?.integration_type === "helper") ||
|
||||
(type === "other" &&
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(!domainUsed && manifest?.integration_type === "entity") ||
|
||||
!["helper", "entity"].includes(manifest?.integration_type || "")))
|
||||
) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
|
||||
`,
|
||||
key: `${DYNAMIC_PREFIX}${domain}`,
|
||||
name: domainToName(localize, domain, manifest),
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
return result.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
};
|
||||
|
||||
private _domainMatchesGroupType(
|
||||
private _classifyDomain(
|
||||
domain: string,
|
||||
manifest: DomainManifestLookup[string] | undefined,
|
||||
domainUsed: boolean,
|
||||
types: CollectionGroupType[]
|
||||
): boolean {
|
||||
const matchDynamic =
|
||||
((types.includes("dynamic") && (!manifest || manifest.is_built_in)) ||
|
||||
(types.includes("customDynamic") &&
|
||||
!(manifest?.is_built_in ?? true))) &&
|
||||
(ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain) &&
|
||||
(domainUsed || (this._systemDomains?.active.has(domain) ?? false))) ||
|
||||
(manifest?.integration_type === "system" &&
|
||||
(this._systemDomains?.active.has(domain) ?? false)));
|
||||
options: DomainClassificationOptions
|
||||
): CollectionGroupType | undefined {
|
||||
const integrationType = manifest?.integration_type;
|
||||
|
||||
const matchHelper =
|
||||
types.includes("helper") && manifest?.integration_type === "helper";
|
||||
if (integrationType === "helper") {
|
||||
return "helper";
|
||||
}
|
||||
|
||||
const matchOther =
|
||||
types.includes("other") &&
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
!["helper", "entity", "system"].includes(
|
||||
manifest?.integration_type || ""
|
||||
));
|
||||
if (ENTITY_DOMAINS_MAIN.has(domain) || integrationType === "entity") {
|
||||
// Core entity domains. Actions always list them; triggers/conditions
|
||||
// only when matching entities exist or a system domain covers them.
|
||||
if (
|
||||
options.type === "action" ||
|
||||
!options.usedDomains ||
|
||||
options.usedDomains.has(domain) ||
|
||||
ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(options.activeSystemDomains?.has(domain) ?? false)
|
||||
) {
|
||||
return "dynamic";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return matchDynamic || matchHelper || matchOther;
|
||||
if (integrationType === "system" && options.type !== "action") {
|
||||
return options.activeSystemDomains?.has(domain) ? "dynamic" : undefined;
|
||||
}
|
||||
|
||||
// Integrations that bring their own elements, built-in (like Apple TV,
|
||||
// FFmpeg) and custom (like HACS) alike.
|
||||
return "integration";
|
||||
}
|
||||
|
||||
private _triggerGroups = (
|
||||
private _dynamicDomainGroups = (
|
||||
localize: LocalizeFunc,
|
||||
triggers: TriggerDescriptions,
|
||||
domains: string[],
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
types: CollectionGroupType[],
|
||||
exclusiveDomains: Set<string>
|
||||
options: DomainClassificationOptions & { exclusiveDomains?: Set<string> }
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!triggers || !manifests) {
|
||||
if (!manifests) {
|
||||
return [];
|
||||
}
|
||||
const result: AddAutomationElementListItem[] = [];
|
||||
const addedDomains = new Set<string>();
|
||||
Object.keys(triggers).forEach((trigger) => {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
|
||||
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
|
||||
domains.forEach((domain) => {
|
||||
if (addedDomains.has(domain) || options.exclusiveDomains?.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
const groupType = this._classifyDomain(domain, manifest, options);
|
||||
|
||||
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
|
||||
if (groupType && types.includes(groupType)) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
|
||||
@@ -1525,46 +1411,6 @@ class DialogAddAutomationElement
|
||||
}
|
||||
);
|
||||
|
||||
private _conditionGroups = (
|
||||
localize: LocalizeFunc,
|
||||
conditions: ConditionDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
types: CollectionGroupType[],
|
||||
exclusiveDomains: Set<string>
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!conditions || !manifests) {
|
||||
return [];
|
||||
}
|
||||
const result: AddAutomationElementListItem[] = [];
|
||||
const addedDomains = new Set<string>();
|
||||
Object.keys(conditions).forEach((condition) => {
|
||||
const domain = getConditionDomain(condition);
|
||||
|
||||
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
|
||||
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
|
||||
`,
|
||||
key: `${DYNAMIC_PREFIX}${domain}`,
|
||||
name: domainToName(localize, domain, manifest),
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
return result.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
};
|
||||
|
||||
private _conditions = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
@@ -1665,26 +1511,13 @@ class DialogAddAutomationElement
|
||||
);
|
||||
}
|
||||
|
||||
if (group && !["helpers", "other"].includes(group)) {
|
||||
if (group) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Object.keys(services)
|
||||
.sort()
|
||||
.forEach((dmn) => {
|
||||
const manifest = manifests?.[dmn];
|
||||
if (group === "helpers" && manifest?.integration_type !== "helper") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
group === "other" &&
|
||||
(ENTITY_DOMAINS_OTHER.has(dmn) ||
|
||||
["helper", "entity"].includes(manifest?.integration_type || ""))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
addDomain(dmn);
|
||||
});
|
||||
.forEach((dmn) => addDomain(dmn));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1695,25 +1528,20 @@ class DialogAddAutomationElement
|
||||
);
|
||||
|
||||
private _getDomainType(domain: string) {
|
||||
if (
|
||||
ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(this._manifests?.[domain]?.integration_type === "entity" &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain) &&
|
||||
(this._domains?.has(domain) ||
|
||||
(this._systemDomains?.active.has(domain) ?? false)))
|
||||
) {
|
||||
return "dynamicGroups";
|
||||
}
|
||||
if (this._manifests?.[domain]?.integration_type === "helper") {
|
||||
const groupType = this._classifyDomain(domain, this._manifests?.[domain], {
|
||||
type: this._params!.type,
|
||||
usedDomains: this._domains,
|
||||
activeSystemDomains: this._systemDomains?.active,
|
||||
});
|
||||
if (groupType === "helper") {
|
||||
return "helpers";
|
||||
}
|
||||
if (
|
||||
this._manifests?.[domain]?.integration_type === "system" &&
|
||||
this._systemDomains?.active.has(domain)
|
||||
) {
|
||||
return "dynamicGroups";
|
||||
if (groupType === "integration") {
|
||||
return "integrationGroups";
|
||||
}
|
||||
return "other";
|
||||
// "dynamic", plus domains hidden in the by-type list (like unused entity
|
||||
// domains) that can still surface when browsing by target.
|
||||
return "dynamicGroups";
|
||||
}
|
||||
|
||||
private _sortDomainsByCollection(
|
||||
|
||||
@@ -346,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
mdiPuzzle,
|
||||
mdiRadioTower,
|
||||
mdiRemote,
|
||||
mdiRenameOutline,
|
||||
mdiRobot,
|
||||
mdiScrewdriver,
|
||||
mdiScriptText,
|
||||
@@ -494,6 +495,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/entity-id-format",
|
||||
translationKey: "entity_id_format",
|
||||
iconPath: mdiRenameOutline,
|
||||
iconColor: "#24a5cb",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/analytics",
|
||||
translationKey: "analytics",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiRestore } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { apiContext, configContext } from "../../../data/context";
|
||||
import {
|
||||
fetchEntityRegistrySettings,
|
||||
updateEntityRegistrySettings,
|
||||
} from "../../../data/entity/entity_registry_settings";
|
||||
import {
|
||||
DEFAULT_ENTITY_ID_FORMAT,
|
||||
isDefaultEntityIdFormat,
|
||||
type EntityIdFormat,
|
||||
type EntityIdPart,
|
||||
} from "../../../data/entity_id_format";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "./ha-entity-id-format-editor";
|
||||
|
||||
const EXAMPLE_DOMAIN = "sensor";
|
||||
|
||||
@customElement("ha-config-entity-id-format")
|
||||
export class HaConfigEntityIdFormat extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _format?: EntityIdFormat;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
try {
|
||||
const settings = await fetchEntityRegistrySettings(this._api);
|
||||
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _examples: Record<EntityIdPart, string> = {
|
||||
area: "Living room",
|
||||
device: "Thermostat",
|
||||
entity: "Temperature",
|
||||
floor: "Ground floor",
|
||||
};
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<p class="description">
|
||||
${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.description"
|
||||
)}
|
||||
<a
|
||||
href=${documentationUrl(
|
||||
this._config,
|
||||
"/docs/configuration/customizing-devices/"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.learn_more"
|
||||
)}</a
|
||||
>
|
||||
</p>
|
||||
${
|
||||
this._format
|
||||
? html`
|
||||
<ha-entity-id-format-editor
|
||||
.value=${this._format}
|
||||
.label=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.label"
|
||||
)}
|
||||
@value-changed=${this._formatChanged}
|
||||
></ha-entity-id-format-editor>
|
||||
${this._renderPreview()}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._reset}
|
||||
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.entity_id_format.card.reset")}
|
||||
</ha-button>
|
||||
<ha-progress-button
|
||||
appearance="filled"
|
||||
@click=${this._save}
|
||||
.disabled=${!this._format}
|
||||
>
|
||||
${this._localize("ui.common.save")}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPreview() {
|
||||
const example = computeEntityIdFormatExample(this._format!, this._examples);
|
||||
return html`
|
||||
<div class="preview">
|
||||
<span class="preview-label">
|
||||
${this._localize("ui.panel.config.entity_id_format.card.preview")}
|
||||
</span>
|
||||
<code>${EXAMPLE_DOMAIN}.${example}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _formatChanged(ev: CustomEvent) {
|
||||
this._format = ev.detail.value;
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
|
||||
}
|
||||
|
||||
private async _save(ev: Event) {
|
||||
const button = ev.currentTarget as HaProgressButton;
|
||||
if (button.progress) {
|
||||
return;
|
||||
}
|
||||
button.progress = true;
|
||||
this._error = undefined;
|
||||
try {
|
||||
const format = this._format!;
|
||||
const settings = await updateEntityRegistrySettings(this._api, {
|
||||
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
|
||||
});
|
||||
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
|
||||
button.actionSuccess();
|
||||
} catch (err: any) {
|
||||
button.actionError();
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
button.progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.description {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-1);
|
||||
margin-top: var(--ha-space-5);
|
||||
}
|
||||
.preview-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.preview code {
|
||||
display: block;
|
||||
padding: var(--ha-space-3);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background-color: var(--secondary-background-color);
|
||||
font-family: var(--ha-font-family-code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-entity-id-format": HaConfigEntityIdFormat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-config-entity-id-format";
|
||||
|
||||
@customElement("ha-config-section-entity-id-format")
|
||||
export class HaConfigSectionEntityIdFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.entity_id_format.caption"
|
||||
)}
|
||||
>
|
||||
<div class="content">
|
||||
<ha-config-entity-id-format></ha-config-entity-id-format>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
padding: var(--ha-space-7) var(--ha-space-5) 0;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
}
|
||||
ha-config-entity-id-format {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/chips/ha-assist-chip";
|
||||
import "../../../components/chips/ha-chip-set";
|
||||
import "../../../components/chips/ha-input-chip";
|
||||
import "../../../components/ha-combo-box-item";
|
||||
import "../../../components/ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type {
|
||||
EntityIdFormat,
|
||||
EntityIdPart,
|
||||
} from "../../../data/entity_id_format";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
|
||||
|
||||
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
<span slot="headline">${item.primary}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const encodeType = (type: EntityIdPart) => `___${type}___`;
|
||||
|
||||
const decodeType = (value: string): EntityIdPart | undefined => {
|
||||
const type =
|
||||
value.startsWith("___") && value.endsWith("___")
|
||||
? value.slice(3, -3)
|
||||
: value;
|
||||
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
|
||||
? (type as EntityIdPart)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@customElement("ha-entity-id-format-editor")
|
||||
export class HaEntityIdFormatEditor extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public value: EntityIdFormat = [];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="container">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.disabled=${this.disabled}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchLabel=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="field">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this.value,
|
||||
(item) => item,
|
||||
(item: EntityIdPart, idx) => {
|
||||
const label = this._formatType(item);
|
||||
if (REQUIRED_TYPES.includes(item)) {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
filled
|
||||
class="required"
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${
|
||||
this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.editor.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`
|
||||
}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx || "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const newValue = this.value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this.value];
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = decodeType(value);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this.value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = type;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(type);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: EntityIdFormat) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _formatType = (type: EntityIdPart) =>
|
||||
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(localize: LocalizeFunc): PickerComboBoxItem[] =>
|
||||
STRUCTURAL_TYPES.map((type) => {
|
||||
const primary = localize(
|
||||
`ui.components.entity.entity-name-picker.types.${type}`
|
||||
);
|
||||
const id = encodeType(type);
|
||||
return {
|
||||
id,
|
||||
primary,
|
||||
search_labels: { primary, id },
|
||||
sorting_label: primary,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
const item = this.value[this._editIndex];
|
||||
return item ? encodeType(item) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getFilteredItems = (): PickerComboBoxItem[] => {
|
||||
const items = this._getItems(this._localize);
|
||||
const currentValue =
|
||||
this._editIndex != null
|
||||
? encodeType(this.value[this._editIndex])
|
||||
: undefined;
|
||||
|
||||
const usedValues = new Set(this.value.map((item) => encodeType(item)));
|
||||
|
||||
return items.filter(
|
||||
(item) => !usedValues.has(item.id) || item.id === currentValue
|
||||
);
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.field:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
:host([disabled]) .field:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
.field:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-assist-chip.required {
|
||||
--ha-assist-chip-filled-container-color: rgba(
|
||||
var(--rgb-primary-text-color),
|
||||
0.15
|
||||
);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
|
||||
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-list-item";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
|
||||
import { fetchLinkedDevices } from "../../../../data/device/device_registry";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { brandsUrl } from "../../../../util/brands-url";
|
||||
|
||||
@customElement("ha-device-linked-devices-card")
|
||||
export class HaDeviceLinkedDevicesCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public deviceId!: string;
|
||||
|
||||
@property({ attribute: false }) public entries!: ConfigEntry[];
|
||||
|
||||
@state() private _linkedDeviceIds: string[] = [];
|
||||
|
||||
private _entryLookup = memoizeOne(
|
||||
(entries: ConfigEntry[]): Record<string, ConfigEntry> => {
|
||||
const lookup: Record<string, ConfigEntry> = {};
|
||||
for (const entry of entries) {
|
||||
lookup[entry.entry_id] = entry;
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
);
|
||||
|
||||
private _linkedDevices = memoizeOne(
|
||||
(
|
||||
linkedDeviceIds: string[],
|
||||
devices: Record<string, DeviceRegistryEntry>
|
||||
): DeviceRegistryEntry[] =>
|
||||
linkedDeviceIds
|
||||
.map((id) => devices[id])
|
||||
.filter((device): device is DeviceRegistryEntry => Boolean(device))
|
||||
.sort((d1, d2) =>
|
||||
caseInsensitiveStringCompare(
|
||||
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
|
||||
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
|
||||
this.hass.locale.language
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("deviceId")) {
|
||||
this._fetchLinkedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const linkedDevices = this._linkedDevices(
|
||||
this._linkedDeviceIds,
|
||||
this.hass.devices
|
||||
);
|
||||
|
||||
if (linkedDevices.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const entryLookup = this._entryLookup(this.entries ?? []);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<h1 class="card-header">
|
||||
${this.hass.localize("ui.panel.config.devices.linked_devices.heading")}
|
||||
</h1>
|
||||
<div class="card-content">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.linked_devices.description"
|
||||
)}
|
||||
</div>
|
||||
${linkedDevices.map((linkedDevice) => {
|
||||
const domain = this._deviceDomain(linkedDevice, entryLookup);
|
||||
const integrationName = domain
|
||||
? domainToName(this.hass.localize, domain)
|
||||
: undefined;
|
||||
return html`
|
||||
<a href=${`/config/devices/device/${linkedDevice.id}`}>
|
||||
<ha-list-item
|
||||
graphic=${domain ? "icon" : nothing}
|
||||
hasMeta
|
||||
.twoline=${!!integrationName}
|
||||
>
|
||||
${
|
||||
domain
|
||||
? html`<img
|
||||
slot="graphic"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>`
|
||||
: nothing
|
||||
}
|
||||
${computeDeviceNameDisplay(
|
||||
linkedDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)}
|
||||
${
|
||||
integrationName
|
||||
? html`<span slot="secondary">${integrationName}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
</a>
|
||||
`;
|
||||
})}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceDomain(
|
||||
device: DeviceRegistryEntry,
|
||||
entryLookup: Record<string, ConfigEntry>
|
||||
): string | undefined {
|
||||
const entryId =
|
||||
device.primary_config_entry ?? device.config_entries[0] ?? undefined;
|
||||
const entry = entryId ? entryLookup[entryId] : undefined;
|
||||
return entry?.domain;
|
||||
}
|
||||
|
||||
private async _fetchLinkedDevices() {
|
||||
const deviceId = this.deviceId;
|
||||
if (!deviceId) {
|
||||
this._linkedDeviceIds = [];
|
||||
return;
|
||||
}
|
||||
let linkedDeviceIds: string[];
|
||||
try {
|
||||
linkedDeviceIds = await fetchLinkedDevices(this.hass, deviceId);
|
||||
} catch (_err) {
|
||||
linkedDeviceIds = [];
|
||||
}
|
||||
if (this.deviceId !== deviceId) {
|
||||
// Device changed while fetching, ignore stale result.
|
||||
return;
|
||||
}
|
||||
this._linkedDeviceIds = linkedDeviceIds;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--secondary-text-color);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
ha-list-item img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-device-linked-devices-card": HaDeviceLinkedDevicesCard;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ import { fileDownload } from "../../../util/file_download";
|
||||
import "../../logbook/ha-logbook";
|
||||
import "./device-detail/ha-device-entities-card";
|
||||
import "./device-detail/ha-device-info-card";
|
||||
import "./device-detail/ha-device-linked-devices-card";
|
||||
import "./device-detail/ha-device-via-devices-card";
|
||||
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
|
||||
import {
|
||||
@@ -892,6 +893,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</ha-device-info-card>
|
||||
<ha-device-linked-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
.entries=${this.entries}
|
||||
></ha-device-linked-devices-card>
|
||||
`;
|
||||
|
||||
const entitiesColumn = html`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
@@ -29,10 +29,11 @@ import "./ha-energy-power-config";
|
||||
import {
|
||||
buildPowerExcludeList,
|
||||
getInitialPowerConfig,
|
||||
getPowerHelperEntityId,
|
||||
getPowerTypeFromConfig,
|
||||
type HaEnergyPowerConfig,
|
||||
isPowerConfigValid,
|
||||
type PowerType,
|
||||
} from "./ha-energy-power-config";
|
||||
} from "./power-config";
|
||||
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
|
||||
@@ -67,8 +68,6 @@ export class DialogEnergyBatterySettings
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
@@ -229,6 +228,10 @@ export class DialogEnergyBatterySettings
|
||||
.powerType=${this._powerType}
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this._params.source,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
@@ -295,11 +298,7 @@ export class DialogEnergyBatterySettings
|
||||
}
|
||||
|
||||
// Check power config validity
|
||||
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return isPowerConfigValid(this._powerType, this._powerConfig);
|
||||
}
|
||||
|
||||
private async _updateMetadata(statId: string) {
|
||||
@@ -374,6 +373,7 @@ export class DialogEnergyBatterySettings
|
||||
if (this._source.capacity === undefined) {
|
||||
delete this._source.capacity;
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
@@ -33,10 +33,11 @@ import "./ha-energy-power-config";
|
||||
import {
|
||||
buildPowerExcludeList,
|
||||
getInitialPowerConfig,
|
||||
getPowerHelperEntityId,
|
||||
getPowerTypeFromConfig,
|
||||
type HaEnergyPowerConfig,
|
||||
isPowerConfigValid,
|
||||
type PowerType,
|
||||
} from "./ha-energy-power-config";
|
||||
} from "./power-config";
|
||||
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
|
||||
@@ -77,8 +78,6 @@ export class DialogEnergyGridSettings
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
@@ -471,6 +470,10 @@ export class DialogEnergyGridSettings
|
||||
.powerType=${this._powerType}
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this._params.source,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.grid.dialog"}
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
@@ -508,10 +511,8 @@ export class DialogEnergyGridSettings
|
||||
}
|
||||
|
||||
// Check power config validity (if power is configured)
|
||||
if (hasPower) {
|
||||
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
|
||||
return false;
|
||||
}
|
||||
if (hasPower && !isPowerConfigValid(this._powerType, this._powerConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,99 +1,24 @@
|
||||
import { mdiInformationOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import { buttonLinkStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
import type { PowerType } from "./power-config";
|
||||
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
/**
|
||||
* Extracts the power type from a PowerConfig object.
|
||||
*/
|
||||
export function getPowerTypeFromConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerType {
|
||||
if (powerConfig) {
|
||||
if (powerConfig.stat_rate_inverted) {
|
||||
return "inverted";
|
||||
}
|
||||
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
|
||||
return "two_sensors";
|
||||
}
|
||||
if (powerConfig.stat_rate) {
|
||||
return "standard";
|
||||
}
|
||||
} else if (statRate) {
|
||||
// Legacy format - treat as standard
|
||||
return "standard";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initial PowerConfig from existing config or legacy stat_rate.
|
||||
*/
|
||||
export function getInitialPowerConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerConfig {
|
||||
if (powerConfig) {
|
||||
return { ...powerConfig };
|
||||
}
|
||||
if (statRate) {
|
||||
return { stat_rate: statRate };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
const powerIds: string[] = [];
|
||||
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentPowerIds = [
|
||||
currentPowerConfig.stat_rate,
|
||||
currentPowerConfig.stat_rate_inverted,
|
||||
currentPowerConfig.stat_rate_from,
|
||||
currentPowerConfig.stat_rate_to,
|
||||
currentStatRate,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return powerIds.filter((id) => !currentPowerIds.includes(id));
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
|
||||
@@ -110,10 +35,14 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public excludeList?: string[];
|
||||
|
||||
/** Entity id of the power sensor generated for the saved config, if any. */
|
||||
@property({ attribute: false }) public helperEntityId?: string;
|
||||
|
||||
/**
|
||||
* Base key for localization lookups.
|
||||
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
|
||||
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
|
||||
* Should include keys for: sensor_type, sensor_type_para, type_none, type_standard,
|
||||
* type_inverted, type_two_sensors, power, power_helper, type_inverted_description,
|
||||
* power_from, power_to, helper_sensor_note, helper_sensor_in_use
|
||||
*/
|
||||
@property({ attribute: false }) public localizeBaseKey =
|
||||
"ui.panel.config.energy.battery.dialog";
|
||||
@@ -164,11 +93,13 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("inverted")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="two_sensors">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("two_sensors")}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
|
||||
@@ -243,9 +174,50 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderHelperSensorInUse()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorNote(powerType: PowerType) {
|
||||
const id = `helper-sensor-note-${powerType}`;
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
id=${id}
|
||||
tabindex="0"
|
||||
class="note-icon"
|
||||
.path=${mdiInformationOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip .for=${id} placement="top">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorInUse() {
|
||||
if (!this.helperEntityId) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<p class="helper-sensor-in-use">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
|
||||
{
|
||||
entity: html`<button class="link" @click=${this._showHelperSensor}>
|
||||
${this.helperEntityId}
|
||||
</button>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private _showHelperSensor() {
|
||||
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
|
||||
}
|
||||
|
||||
private _handlePowerTypeChanged(ev: Event) {
|
||||
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
|
||||
// Clear power config when switching types
|
||||
@@ -289,48 +261,46 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the power config is complete for the selected type.
|
||||
*/
|
||||
public isValid(): boolean {
|
||||
switch (this.powerType) {
|
||||
case "none":
|
||||
return true;
|
||||
case "standard":
|
||||
return !!this.powerConfig.stat_rate;
|
||||
case "inverted":
|
||||
return !!this.powerConfig.stat_rate_inverted;
|
||||
case "two_sensors":
|
||||
return (
|
||||
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
`;
|
||||
static readonly styles: CSSResultGroup = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.note-icon {
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.helper-sensor-in-use {
|
||||
margin: var(--ha-space-2) 0 0 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.helper-sensor-in-use button.link {
|
||||
color: var(--primary-color);
|
||||
/* entity ids offer no break opportunities of their own */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
|
||||
/**
|
||||
* Extracts the power type from a PowerConfig object.
|
||||
*/
|
||||
export function getPowerTypeFromConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerType {
|
||||
if (powerConfig) {
|
||||
if (powerConfig.stat_rate_inverted) {
|
||||
return "inverted";
|
||||
}
|
||||
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
|
||||
return "two_sensors";
|
||||
}
|
||||
if (powerConfig.stat_rate) {
|
||||
return "standard";
|
||||
}
|
||||
} else if (statRate) {
|
||||
// Legacy format - treat as standard
|
||||
return "standard";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initial PowerConfig from existing config or legacy stat_rate.
|
||||
*/
|
||||
export function getInitialPowerConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerConfig {
|
||||
if (powerConfig) {
|
||||
return { ...powerConfig };
|
||||
}
|
||||
if (statRate) {
|
||||
return { stat_rate: statRate };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the power config is complete for the selected power type.
|
||||
*/
|
||||
export function isPowerConfigValid(
|
||||
powerType: PowerType,
|
||||
powerConfig: PowerConfig
|
||||
): boolean {
|
||||
switch (powerType) {
|
||||
case "none":
|
||||
return true;
|
||||
case "standard":
|
||||
return !!powerConfig.stat_rate;
|
||||
case "inverted":
|
||||
return !!powerConfig.stat_rate_inverted;
|
||||
case "two_sensors":
|
||||
return !!powerConfig.stat_rate_from && !!powerConfig.stat_rate_to;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
const powerIds: string[] = [];
|
||||
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentPowerIds = [
|
||||
currentPowerConfig.stat_rate,
|
||||
currentPowerConfig.stat_rate_inverted,
|
||||
currentPowerConfig.stat_rate_from,
|
||||
currentPowerConfig.stat_rate_to,
|
||||
currentStatRate,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return powerIds.filter((id) => !currentPowerIds.includes(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entity id of the power sensor the backend generated for a saved
|
||||
* source, if any. Inverted and two sensor configs get such a helper, its entity
|
||||
* id is stored in `stat_rate`. Returns nothing while the config differs from the
|
||||
* saved one, as the helper doesn't match the edited config yet.
|
||||
*/
|
||||
export function getPowerHelperEntityId(
|
||||
source: { stat_rate?: string; power_config?: PowerConfig } | undefined,
|
||||
currentPowerConfig: PowerConfig
|
||||
): string | undefined {
|
||||
if (!source?.stat_rate || !source.power_config) {
|
||||
return undefined;
|
||||
}
|
||||
const savedPowerType = getPowerTypeFromConfig(source.power_config);
|
||||
if (savedPowerType !== "inverted" && savedPowerType !== "two_sensors") {
|
||||
return undefined;
|
||||
}
|
||||
if (!deepEqual(source.power_config, currentPowerConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
return source.stat_rate;
|
||||
}
|
||||
@@ -171,6 +171,10 @@ class HaPanelConfig extends HassRouterPage {
|
||||
tag: "ha-config-section-ai-tasks",
|
||||
load: () => import("./core/ha-config-section-ai-tasks"),
|
||||
},
|
||||
"entity-id-format": {
|
||||
tag: "ha-config-section-entity-id-format",
|
||||
load: () => import("./core/ha-config-section-entity-id-format"),
|
||||
},
|
||||
zha: {
|
||||
tag: "zha-config-dashboard-router",
|
||||
load: () =>
|
||||
|
||||
@@ -397,8 +397,20 @@ class HaConfigHttpForm extends LitElement {
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
// Drop empty entries from multi-value fields, and omit the field entirely
|
||||
// once it is empty so the backend applies its default. Otherwise a cleared
|
||||
// "IP address to bind to" would submit [""] / [], which binds to nothing.
|
||||
const config = Object.fromEntries(
|
||||
Object.entries(this._config).map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const filtered = value.filter(Boolean);
|
||||
return [key, filtered.length ? filtered : undefined];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
) as HttpConfig;
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
const result = await saveHttpConfig(this.hass, config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-menu-button";
|
||||
@@ -161,7 +162,9 @@ class PanelEnergy extends LitElement {
|
||||
.route=${this.route}
|
||||
.panel=${this.panel}
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
.backPath=${this._searchParms.get("backPath") || "/"}
|
||||
.backPath=${
|
||||
sanitizeNavigationPath(this._searchParms.get("backPath")) || "/"
|
||||
}
|
||||
@reload-energy-panel=${this._reloadConfig}
|
||||
>
|
||||
</hui-root>
|
||||
|
||||
@@ -1,384 +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 { 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 { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-adaptive-dialog";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-relative-time";
|
||||
import "../../components/ha-spinner";
|
||||
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 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 _error = false;
|
||||
|
||||
public showDialog(params: LogbookDetailDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._chain = 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._loadChain();
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._chain = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private async _loadChain() {
|
||||
const { entry, 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();
|
||||
if (this._params?.entry !== entry) {
|
||||
return;
|
||||
}
|
||||
this._chain = chain;
|
||||
} catch {
|
||||
if (this._params?.entry !== entry) {
|
||||
return;
|
||||
}
|
||||
this._error = true;
|
||||
this._chain = await resolveWithoutFetch();
|
||||
}
|
||||
}
|
||||
|
||||
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 = new Date(item.when);
|
||||
const subjectKey =
|
||||
item.category === "entity"
|
||||
? "entity"
|
||||
: item.category === "automation"
|
||||
? entry.domain === "script"
|
||||
? "script"
|
||||
: "automation"
|
||||
: "integration";
|
||||
|
||||
return html`
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize(
|
||||
`ui.dialogs.logbook_detail.${subjectKey}` as LocalizeKeys
|
||||
)}
|
||||
</span>
|
||||
<span class="value">
|
||||
${this._renderName(item.name, entry.entity_id)}
|
||||
${
|
||||
item.context
|
||||
? html`<span class="sub">${item.context}</span>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
transition
|
||||
? html`
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.state")}
|
||||
</span>
|
||||
<span class="value">
|
||||
${
|
||||
transition.oldState
|
||||
? html`<span class="old-state"
|
||||
>${transition.oldState}</span
|
||||
><span class="arrow">${this._arrow()}</span>`
|
||||
: nothing
|
||||
}<span class="new-state">${transition.newState}</span>
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: item.value
|
||||
? html`
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.event")}
|
||||
</span>
|
||||
<span class="value">${item.value.text}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.time")}
|
||||
</span>
|
||||
<span class="value time-value">
|
||||
${formatDateTimeWithSeconds(
|
||||
when,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
<span class="sub">
|
||||
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderWhatHappened(entry: LogbookEntry) {
|
||||
return html`
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
|
||||
</h3>
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize("ui.components.logbook.retrieval_error")}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(name: string | undefined, entityId?: string) {
|
||||
if (entityId && entityId in this.hass.states) {
|
||||
return html`<button
|
||||
class="link name"
|
||||
.entityId=${entityId}
|
||||
@click=${this._entityClicked}
|
||||
>
|
||||
${name}
|
||||
</button>`;
|
||||
}
|
||||
return html`<span class="name">${name}</span>`;
|
||||
}
|
||||
|
||||
private _arrow() {
|
||||
return computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "←"
|
||||
: "→";
|
||||
}
|
||||
|
||||
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._params?.previousState;
|
||||
const oldState =
|
||||
previousState !== undefined && previousState !== entry.state
|
||||
? stateObj
|
||||
? this.hass.formatEntityState(stateObj, previousState)
|
||||
: previousState
|
||||
: undefined;
|
||||
return { oldState, newState };
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const entityId = (ev.currentTarget as HTMLElement & { entityId?: string })
|
||||
.entityId;
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
this.closeDialog();
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
|
||||
private _moreInfoOpened() {
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.box > .row + .row {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
min-height: 48px;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row .label {
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row .value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.value .name {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
button.link.name {
|
||||
color: var(--primary-text-color);
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.link.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-logbook-detail": DialogLogbookDetail;
|
||||
}
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { mdiClockOutline, mdiFlash, mdiHomeAssistant } 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 { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
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 {
|
||||
classifyLogbookEntry,
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
entityDisplay,
|
||||
isSameLogbookEntry,
|
||||
nodeColor,
|
||||
} from "./logbook-entry-model";
|
||||
import {
|
||||
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`
|
||||
<div class="box">
|
||||
<p class="no-cause">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// An entity chain shows only its subject's rows; a run chain shows
|
||||
// everything the run did.
|
||||
const subjectIsRun = classifyLogbookEntry(this.subject) === "automation";
|
||||
const visibleRows = subjectIsRun
|
||||
? rows
|
||||
: rows.filter(
|
||||
(row) =>
|
||||
classifyLogbookEntry(row) === "automation" ||
|
||||
(this.subject.entity_id
|
||||
? row.entity_id === this.subject.entity_id
|
||||
: isSameLogbookEntry(row, this.subject))
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="box">
|
||||
<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) =>
|
||||
classifyLogbookEntry(row) === "automation"
|
||||
? this._renderRunNode(row)
|
||||
: this._renderEffectNode(row)
|
||||
)}
|
||||
</div>
|
||||
</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"
|
||||
);
|
||||
return html`
|
||||
<div class="chain-row">
|
||||
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
|
||||
<span class="chain-content">
|
||||
${this._renderName(cause.name, cause.entityId)}
|
||||
${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">
|
||||
${this._renderName(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: this._actionName(
|
||||
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=${mdiFlash}></ha-svg-icon>`;
|
||||
}
|
||||
if (origin.type === "scheduled") {
|
||||
return html`<ha-svg-icon .path=${mdiClockOutline}></ha-svg-icon>`;
|
||||
}
|
||||
if (origin.type === "homeassistant") {
|
||||
return html`<ha-svg-icon .path=${mdiHomeAssistant}></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">
|
||||
${this._renderName(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">
|
||||
${this._renderName(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 _renderName(name: string | undefined, entityId?: string) {
|
||||
if (entityId && entityId in this.hass.states) {
|
||||
return html`<button
|
||||
class="link name"
|
||||
.entityId=${entityId}
|
||||
@click=${this._entityClicked}
|
||||
>
|
||||
${name}
|
||||
</button>`;
|
||||
}
|
||||
return html`<span class="name">${name}</span>`;
|
||||
}
|
||||
|
||||
private _actionName(domain: string, service: string) {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.name` as LocalizeKeys
|
||||
) ||
|
||||
this.hass.services[domain]?.[service]?.name ||
|
||||
`${domain}.${service}`
|
||||
);
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const entityId = (ev.currentTarget as HTMLElement & { entityId?: string })
|
||||
.entityId;
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
|
||||
private _traceClicked(ev: MouseEvent) {
|
||||
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
// navigate() closes the dialogs above this chain.
|
||||
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
button.link.name {
|
||||
color: var(--primary-text-color);
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.link.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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,52 +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 { ifDefined } from "lit/directives/if-defined";
|
||||
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,
|
||||
} 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 {
|
||||
index: number;
|
||||
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 {
|
||||
@@ -59,10 +65,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
@property({ attribute: false }) public index = -1;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-row-click" }) public noRowClick =
|
||||
false;
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -96,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";
|
||||
@@ -111,12 +125,11 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
const ctx: LogbookRenderItem = {
|
||||
...item,
|
||||
traceLink,
|
||||
renderedTime,
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds),
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
|
||||
};
|
||||
|
||||
const clickable = !this.noRowClick;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="entry ${classMap({
|
||||
@@ -125,12 +138,7 @@ class HaLogbookEntry extends LitElement {
|
||||
"last-of-day": this.lastOfDay,
|
||||
[`category-${ctx.category}`]: true,
|
||||
"time-am-pm": useAmPm(this.hass.locale),
|
||||
clickable,
|
||||
})}"
|
||||
role=${ifDefined(clickable ? "button" : undefined)}
|
||||
tabindex=${ifDefined(clickable ? "0" : undefined)}
|
||||
@click=${this._rowClicked}
|
||||
@keydown=${this._rowKeydown}
|
||||
>
|
||||
${
|
||||
layout === "timeline"
|
||||
@@ -168,36 +176,24 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
private _toggleTime(e: Event) {
|
||||
e.stopPropagation();
|
||||
fireEvent(this, "logbook-toggle-time");
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
}
|
||||
|
||||
private _timeKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fireEvent(this, "logbook-toggle-time");
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
}
|
||||
}
|
||||
|
||||
private _rowClicked() {
|
||||
if (this.noRowClick) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "logbook-entry-selected", {
|
||||
index: this.index,
|
||||
item: this.item,
|
||||
});
|
||||
}
|
||||
|
||||
private _rowKeydown(ev: KeyboardEvent) {
|
||||
if (this.noRowClick || (ev.key !== "Enter" && ev.key !== " ")) {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
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();
|
||||
this._rowClicked();
|
||||
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
|
||||
fireEvent(this, "closed");
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
@@ -221,6 +217,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
private _renderTrailing(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined,
|
||||
renderedTime: TemplateResult | string
|
||||
) {
|
||||
return html`<span class="trailing">
|
||||
@@ -228,14 +225,24 @@ class HaLogbookEntry extends LitElement {
|
||||
cause
|
||||
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
|
||||
<span class="cause-badge" id="cause-badge"
|
||||
>${renderLogbookCauseIcon(cause)}</span
|
||||
>${this._renderCauseIcon(cause)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
${this._renderTimeChip(renderedTime)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -272,9 +279,15 @@ class HaLogbookEntry extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
causePhrase
|
||||
causePhrase || ctx.traceLink
|
||||
? html`<div class="secondary">
|
||||
<span class="cause-phrase">${causePhrase}</span>
|
||||
${
|
||||
causePhrase
|
||||
? html`<span class="cause-phrase">${causePhrase}</span>`
|
||||
: nothing
|
||||
}
|
||||
${causePhrase && ctx.traceLink ? html`·` : nothing}
|
||||
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
@@ -284,7 +297,9 @@ class HaLogbookEntry extends LitElement {
|
||||
private _renderList(ctx: LogbookRenderItem) {
|
||||
const cause =
|
||||
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
|
||||
const showThirdLine = this.showCause && cause;
|
||||
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"
|
||||
@@ -298,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._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) {
|
||||
@@ -338,10 +357,12 @@ class HaLogbookEntry extends LitElement {
|
||||
>
|
||||
${cause.name}
|
||||
</button>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -351,6 +372,7 @@ class HaLogbookEntry extends LitElement {
|
||||
<span class="primary-text">${ctx.renderedValue}</span>
|
||||
${this._renderTrailing(
|
||||
ctx.category === "entity" ? ctx.cause : undefined,
|
||||
ctx.traceLink,
|
||||
ctx.renderedTime
|
||||
)}
|
||||
</div>
|
||||
@@ -359,19 +381,21 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
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 =
|
||||
@@ -382,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 {
|
||||
@@ -444,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(" ");
|
||||
@@ -462,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(" ")}`;
|
||||
}
|
||||
@@ -499,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,
|
||||
@@ -562,19 +672,6 @@ class HaLogbookEntry extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
.entry.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entry.clickable:hover {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.04);
|
||||
}
|
||||
|
||||
.entry.clickable:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -860,7 +957,6 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.time-chip {
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5em;
|
||||
text-align: end;
|
||||
line-height: 1;
|
||||
font-size: var(--ha-font-size-s);
|
||||
@@ -870,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;
|
||||
}
|
||||
@@ -906,6 +1006,18 @@ class HaLogbookEntry extends LitElement {
|
||||
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 { findPreviousState, sameDay } from "./logbook-entry-model";
|
||||
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
|
||||
import { sameDay } from "./logbook-entry-model";
|
||||
|
||||
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,9 +49,6 @@ class HaLogbookRenderer extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
|
||||
false;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-row-click" }) public noRowClick =
|
||||
false;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
|
||||
@@ -83,7 +77,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
return (
|
||||
changedProps.has("entries") ||
|
||||
changedProps.has("noRowClick") ||
|
||||
changedProps.has("traceContexts") ||
|
||||
changedProps.has("_showRelative" as never) ||
|
||||
languageChanged
|
||||
);
|
||||
@@ -103,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
|
||||
@@ -146,9 +139,9 @@ class HaLogbookRenderer extends LitElement {
|
||||
<ha-logbook-entry
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
.userIdToName=${this.userIdToName}
|
||||
.systemUserIds=${this.systemUserIds}
|
||||
.traceContexts=${this.traceContexts}
|
||||
.narrow=${this.narrow}
|
||||
.noIcon=${this.noIcon}
|
||||
.graphColor=${this.graphColor}
|
||||
@@ -157,7 +150,6 @@ class HaLogbookRenderer extends LitElement {
|
||||
.lastOfDay=${lastOfDay}
|
||||
.showRelative=${this._showRelative}
|
||||
.showCause=${this.showCause}
|
||||
.noRowClick=${this.noRowClick}
|
||||
></ha-logbook-entry>
|
||||
</div>
|
||||
`;
|
||||
@@ -167,24 +159,6 @@ class HaLogbookRenderer extends LitElement {
|
||||
this._showRelative = !this._showRelative;
|
||||
}
|
||||
|
||||
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
|
||||
ev.stopPropagation();
|
||||
const { item } = ev.detail;
|
||||
let index = ev.detail.index;
|
||||
if (this.entries[index] !== item) {
|
||||
// A recycled virtualizer row can deliver a stale index.
|
||||
index = this.entries.indexOf(item);
|
||||
}
|
||||
showLogbookDetailDialog(this, {
|
||||
entry: item,
|
||||
previousState:
|
||||
index >= 0 ? findPreviousState(this.entries, index) : undefined,
|
||||
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,180 +0,0 @@
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { getLogbookEvents } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LogbookCause } from "./logbook-entry-model";
|
||||
import {
|
||||
classifyLogbookEntry,
|
||||
computeContextCause,
|
||||
computeLogbookCause,
|
||||
computeUserCause,
|
||||
isRunCause,
|
||||
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.
|
||||
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) =>
|
||||
getLogbookEvents(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((row) => classifyLogbookEntry(row) === "automation");
|
||||
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 = (
|
||||
@@ -97,49 +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);
|
||||
|
||||
// Entries are sorted newest first.
|
||||
export const findPreviousState = (
|
||||
entries: LogbookEntry[],
|
||||
index: number
|
||||
): string | undefined => {
|
||||
const entityId = entries[index]?.entity_id;
|
||||
if (!entityId) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = index + 1; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.entity_id === entityId && entry.state !== undefined) {
|
||||
return entry.state;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -169,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>
|
||||
@@ -177,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"
|
||||
@@ -282,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 }
|
||||
@@ -339,8 +284,10 @@ const computeLogbookValue = (
|
||||
type: "state",
|
||||
};
|
||||
}
|
||||
// Core sends run rows with a raw English message; use our own label.
|
||||
const isAutomationRun = domain && TRIGGER_DOMAINS.includes(domain);
|
||||
const isAutomationRun =
|
||||
domain &&
|
||||
TRIGGER_DOMAINS.includes(domain) &&
|
||||
(item.source || hasContext(item) || !!item.context_user_id);
|
||||
if (isAutomationRun) {
|
||||
return {
|
||||
text: hass.localize(
|
||||
@@ -401,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),
|
||||
@@ -415,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,109 +0,0 @@
|
||||
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
|
||||
import { html, nothing } from "lit";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
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 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 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>`;
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
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,24 +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;
|
||||
previousState?: string;
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import { apiContext, servicesContext } from "../../../data/context";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
import {
|
||||
hasRequiredScriptFieldsForServices,
|
||||
requiredScriptFieldsFilledForServices,
|
||||
@@ -96,6 +97,8 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
: {}),
|
||||
};
|
||||
|
||||
forwardHaptic(this, "light");
|
||||
|
||||
this._api.callService(domain, service, serviceData);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
import {
|
||||
addSearchParam,
|
||||
extractSearchParamsObject,
|
||||
@@ -893,10 +894,12 @@ class HUIRoot extends LitElement {
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
if (curViewConfig?.back_path != null) {
|
||||
navigate(curViewConfig.back_path, { replace: true });
|
||||
} else if (this.backPath) {
|
||||
navigate(this.backPath, { replace: true });
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
navigate(backPath, { replace: true });
|
||||
} else if (history.length > 1) {
|
||||
goBack();
|
||||
} else if (!views[0].subview) {
|
||||
@@ -918,11 +921,12 @@ class HUIRoot extends LitElement {
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
if (curViewConfig?.back_path != null) {
|
||||
return curViewConfig.back_path;
|
||||
}
|
||||
if (this.backPath) {
|
||||
return this.backPath;
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
return backPath;
|
||||
}
|
||||
return curViewConfig?.subview ? this.route!.prefix : undefined;
|
||||
}
|
||||
|
||||
+34
-23
@@ -2046,19 +2046,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",
|
||||
@@ -4224,7 +4211,9 @@
|
||||
"power_stat": "Power sensor",
|
||||
"power_helper": "Pick a sensor which measures grid power in either of {unit}.",
|
||||
"power_from": "Power imported from grid",
|
||||
"power_to": "Power exported to grid"
|
||||
"power_to": "Power exported to grid",
|
||||
"helper_sensor_note": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_note%]",
|
||||
"helper_sensor_in_use": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_in_use%]"
|
||||
},
|
||||
"flow_dialog": {
|
||||
"from": {
|
||||
@@ -4291,7 +4280,9 @@
|
||||
"type_inverted_description": "Positive values indicate charging, negative values indicate discharging.",
|
||||
"type_two_sensors": "Two sensors",
|
||||
"power_from": "Discharge power",
|
||||
"power_to": "Charge power"
|
||||
"power_to": "Charge power",
|
||||
"helper_sensor_note": "Home Assistant will create a helper sensor entity for this option.",
|
||||
"helper_sensor_in_use": "Currently using helper {entity}"
|
||||
}
|
||||
},
|
||||
"gas": {
|
||||
@@ -5292,8 +5283,8 @@
|
||||
"generic": {
|
||||
"label": "Generic"
|
||||
},
|
||||
"custom_integrations": {
|
||||
"label": "Custom integrations"
|
||||
"integrations": {
|
||||
"label": "Integrations"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -5604,8 +5595,8 @@
|
||||
"building_blocks": {
|
||||
"label": "Building blocks"
|
||||
},
|
||||
"custom_integrations": {
|
||||
"label": "Custom integrations"
|
||||
"integrations": {
|
||||
"label": "Integrations"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -5774,11 +5765,11 @@
|
||||
"device_id": {
|
||||
"label": "Device"
|
||||
},
|
||||
"helpers": {
|
||||
"label": "Helpers"
|
||||
"generic": {
|
||||
"label": "Generic"
|
||||
},
|
||||
"other": {
|
||||
"label": "Other actions"
|
||||
"integrations": {
|
||||
"label": "Integrations"
|
||||
},
|
||||
"building_blocks": {
|
||||
"label": "Building blocks"
|
||||
@@ -6792,6 +6783,10 @@
|
||||
"heading": "Connected devices",
|
||||
"show_more": "+{count} devices not shown"
|
||||
},
|
||||
"linked_devices": {
|
||||
"heading": "Linked devices",
|
||||
"description": "These devices share hardware with this device and are managed by other integrations."
|
||||
},
|
||||
"type": {
|
||||
"device_heading": "Device",
|
||||
"device": "device",
|
||||
@@ -8645,6 +8640,22 @@
|
||||
"caption": "AI tasks",
|
||||
"description": "Configure AI suggestions and task preferences"
|
||||
},
|
||||
"entity_id_format": {
|
||||
"caption": "Entity ID format",
|
||||
"description": "Manage the format of newly created entity IDs",
|
||||
"card": {
|
||||
"header": "Entity ID format for new entities",
|
||||
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
|
||||
"learn_more": "Learn more",
|
||||
"preview": "Preview",
|
||||
"reset": "Reset to default",
|
||||
"editor": {
|
||||
"label": "Format",
|
||||
"add": "Add",
|
||||
"search": "Search part"
|
||||
}
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"caption": "Labs",
|
||||
"custom_integration": "Custom integration",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalAttachInternals = HTMLElement.prototype.attachInternals;
|
||||
const originalElementInternals = window.ElementInternals;
|
||||
|
||||
// The probe memoizes its result, so re-import the module for each scenario.
|
||||
const loadProbe = async () => {
|
||||
vi.resetModules();
|
||||
const mod =
|
||||
await import("../../../src/common/feature-detect/support-native-element-internals");
|
||||
return mod.supportsNativeElementInternals;
|
||||
};
|
||||
|
||||
describe("supportsNativeElementInternals", () => {
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.attachInternals = originalAttachInternals;
|
||||
window.ElementInternals = originalElementInternals;
|
||||
});
|
||||
|
||||
it("returns true with native ElementInternals", async () => {
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when attachInternals is wrapped by a delegating function", async () => {
|
||||
// Simulates @webcomponents/scoped-custom-element-registry, which the app
|
||||
// bundle loads. Wrapping used to make detection fail (#53337).
|
||||
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
|
||||
return originalAttachInternals.call(this);
|
||||
};
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when element-internals-polyfill replaces the global", async () => {
|
||||
// The polyfill swaps in its own class, so the mere presence of
|
||||
// window.ElementInternals says nothing about native support (#51338).
|
||||
class PolyfilledElementInternals {
|
||||
public setFormValue = (): void => undefined;
|
||||
|
||||
public setValidity = (): void => undefined;
|
||||
|
||||
public checkValidity = (): boolean => true;
|
||||
|
||||
public reportValidity = (): boolean => true;
|
||||
}
|
||||
window.ElementInternals =
|
||||
PolyfilledElementInternals as unknown as typeof window.ElementInternals;
|
||||
HTMLElement.prototype.attachInternals = () =>
|
||||
new PolyfilledElementInternals() as unknown as ElementInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without ElementInternals", async () => {
|
||||
window.ElementInternals =
|
||||
undefined as unknown as typeof window.ElementInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without attachInternals", async () => {
|
||||
HTMLElement.prototype.attachInternals =
|
||||
undefined as unknown as typeof originalAttachInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("probes on first use rather than on import", async () => {
|
||||
// Imported while support is present, so an eager probe would cache true.
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
window.ElementInternals =
|
||||
undefined as unknown as typeof window.ElementInternals;
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeNavigationPath } from "../../../src/common/url/sanitize-navigation-path";
|
||||
|
||||
describe("sanitizeNavigationPath", () => {
|
||||
it("keeps paths on the current origin", () => {
|
||||
expect(sanitizeNavigationPath("/")).toEqual("/");
|
||||
expect(sanitizeNavigationPath("/config/areas")).toEqual("/config/areas");
|
||||
expect(sanitizeNavigationPath("/energy?historyBack=1")).toEqual(
|
||||
"/energy?historyBack=1"
|
||||
);
|
||||
expect(sanitizeNavigationPath("config/areas")).toEqual("config/areas");
|
||||
expect(sanitizeNavigationPath(`${location.origin}/lovelace/0`)).toEqual(
|
||||
`${location.origin}/lovelace/0`
|
||||
);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it("rejects URIs with their own scheme", () => {
|
||||
expect(sanitizeNavigationPath("javascript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("JavaScript:alert(1)")).toBeUndefined();
|
||||
// the URL parser strips tabs and newlines, just like the browser does for href
|
||||
expect(sanitizeNavigationPath("java\tscript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath(" javascript:alert(1)")).toBeUndefined();
|
||||
expect(
|
||||
sanitizeNavigationPath("data:text/html,<script>alert(1)</script>")
|
||||
).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("vbscript:msgbox(1)")).toBeUndefined();
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
it("rejects other origins", () => {
|
||||
expect(sanitizeNavigationPath("https://example.com/")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("//example.com/")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("\\\\example.com/")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects missing values", () => {
|
||||
expect(sanitizeNavigationPath(undefined)).toBeUndefined();
|
||||
expect(sanitizeNavigationPath(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { LitElement } from "lit";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WaInput } from "../../../src/components/input/wa-input-mixin";
|
||||
import { WaInputMixin } from "../../../src/components/input/wa-input-mixin";
|
||||
|
||||
const supportsNative = vi.hoisted(() => ({ value: true }));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/common/feature-detect/support-native-element-internals",
|
||||
() => ({
|
||||
supportsNativeElementInternals: () => supportsNative.value,
|
||||
})
|
||||
);
|
||||
|
||||
// Subclassing gives legitimate access to the mixin's protected members, which
|
||||
// ha-input and ha-textarea reach through their @input/@blur/@wa-invalid bindings.
|
||||
class TestInput extends WaInputMixin(LitElement) {
|
||||
public controlValid = true;
|
||||
|
||||
protected get _formControl(): WaInput {
|
||||
return {
|
||||
value: "",
|
||||
select: () => undefined,
|
||||
setSelectionRange: () => undefined,
|
||||
setRangeText: () => undefined,
|
||||
checkValidity: () => this.controlValid,
|
||||
validationMessage: "Please fill out this field.",
|
||||
};
|
||||
}
|
||||
|
||||
public get isInvalid(): boolean {
|
||||
return this._invalid;
|
||||
}
|
||||
|
||||
public set isInvalid(value: boolean) {
|
||||
this._invalid = value;
|
||||
}
|
||||
|
||||
public handleInput(): void {
|
||||
this._handleInput();
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._handleBlur();
|
||||
}
|
||||
|
||||
public handleInvalid(): void {
|
||||
this._handleInvalid();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-wa-input-mixin", TestInput);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-wa-input-mixin": TestInput;
|
||||
}
|
||||
}
|
||||
|
||||
const createInput = (props: Partial<TestInput> = {}): TestInput => {
|
||||
const el = document.createElement("test-wa-input-mixin");
|
||||
Object.assign(el, props);
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("WaInputMixin validity", () => {
|
||||
beforeEach(() => {
|
||||
supportsNative.value = true;
|
||||
});
|
||||
|
||||
describe("with native element internals", () => {
|
||||
it("marks invalid on blur when auto-validate is set", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps valid on blur when the control is valid", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: true });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores blur without auto-validate", () => {
|
||||
const el = createInput({ autoValidate: false, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid on input once the control is valid", () => {
|
||||
const el = createInput({ controlValid: true });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps invalid on input while the control is invalid", () => {
|
||||
const el = createInput({ controlValid: false });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it("marks invalid on the invalid event", () => {
|
||||
const el = createInput();
|
||||
el.handleInvalid();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Polyfilled internals report validity the app cannot trust, so every path
|
||||
// must agree with checkValidity() and leave the field alone (#51338).
|
||||
describe("without native element internals", () => {
|
||||
beforeEach(() => {
|
||||
supportsNative.value = false;
|
||||
});
|
||||
|
||||
it("does not mark invalid on blur", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid on input", () => {
|
||||
const el = createInput({ controlValid: false });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the invalid event", () => {
|
||||
const el = createInput();
|
||||
el.handleInvalid();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("reports valid so submission is never blocked", () => {
|
||||
const el = createInput({ required: true, controlValid: false });
|
||||
expect(el.checkValidity()).toBe(true);
|
||||
expect(el.reportValidity()).toBe(true);
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-subpage");
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (backPath: string) => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const element = document.createElement("hass-subpage");
|
||||
element.setAttribute("back-path", backPath);
|
||||
host.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
describe("hass-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config/system");
|
||||
expect(backButton!.getAttribute("href")).toEqual("/config/system");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
it.each(["javascript:alert(1)", "https://example.com/"])(
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.hasAttribute("href")).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
vi.mock("../../src/components/ha-tab", () => ({}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
customElements.define("ha-tab", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-tabs-subpage");
|
||||
|
||||
const hass = {
|
||||
config: { components: [] },
|
||||
language: "en",
|
||||
localize: (key: string) => key,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (backPath: string) => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const element = document.createElement(
|
||||
"hass-tabs-subpage"
|
||||
) as HassTabsSubpage;
|
||||
Object.assign(element, {
|
||||
hass,
|
||||
route: { prefix: "", path: "" },
|
||||
tabs: [],
|
||||
backPath,
|
||||
});
|
||||
host.append(element);
|
||||
await element.updateComplete;
|
||||
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev") as
|
||||
(LitElement & { href?: string }) | null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
describe("hass-tabs-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config");
|
||||
expect(backButton!.href).toEqual("/config");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
it.each(["javascript:alert(1)", "https://example.com/"])(
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.href).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("getPowerHelperEntityId", () => {
|
||||
it("returns the helper for an inverted config", () => {
|
||||
const powerConfig = { stat_rate_inverted: "sensor.battery_power" };
|
||||
assert.strictEqual(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.battery_power_inverted",
|
||||
power_config: powerConfig,
|
||||
},
|
||||
{ ...powerConfig }
|
||||
),
|
||||
"sensor.battery_power_inverted"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the helper for a two sensor config", () => {
|
||||
const powerConfig = {
|
||||
stat_rate_from: "sensor.discharge",
|
||||
stat_rate_to: "sensor.charge",
|
||||
};
|
||||
assert.strictEqual(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.energy_battery_discharge_charge_net_power",
|
||||
power_config: powerConfig,
|
||||
},
|
||||
{ ...powerConfig }
|
||||
),
|
||||
"sensor.energy_battery_discharge_charge_net_power"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for a standard config, as no helper is created", () => {
|
||||
const powerConfig = { stat_rate: "sensor.battery_power" };
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(
|
||||
{ stat_rate: "sensor.battery_power", power_config: powerConfig },
|
||||
{ ...powerConfig }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for a legacy config without power_config", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId({ stat_rate: "sensor.battery_power" }, {})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing when the config was edited", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.battery_power_inverted",
|
||||
power_config: { stat_rate_inverted: "sensor.battery_power" },
|
||||
},
|
||||
{ stat_rate_inverted: "sensor.other_battery_power" }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an unsaved source", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(undefined, {
|
||||
stat_rate_inverted: "sensor.battery_power",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isPowerConfigValid } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("isPowerConfigValid", () => {
|
||||
it("accepts any config when no power sensor is configured", () => {
|
||||
expect(isPowerConfigValid("none", {})).toBe(true);
|
||||
expect(isPowerConfigValid("none", { stat_rate: "sensor.power" })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a rate statistic for the standard type", () => {
|
||||
expect(isPowerConfigValid("standard", {})).toBe(false);
|
||||
expect(isPowerConfigValid("standard", { stat_rate: "" })).toBe(false);
|
||||
expect(isPowerConfigValid("standard", { stat_rate: "sensor.power" })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isPowerConfigValid("standard", { stat_rate_inverted: "sensor.power" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an inverted rate statistic for the inverted type", () => {
|
||||
expect(isPowerConfigValid("inverted", {})).toBe(false);
|
||||
expect(isPowerConfigValid("inverted", { stat_rate: "sensor.power" })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isPowerConfigValid("inverted", { stat_rate_inverted: "sensor.power" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires both statistics for the two sensors type", () => {
|
||||
expect(isPowerConfigValid("two_sensors", {})).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", { stat_rate_from: "sensor.power_from" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", { stat_rate_to: "sensor.power_to" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
stat_rate_to: "sensor.power_to",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown power types", () => {
|
||||
expect(
|
||||
isPowerConfigValid("unexpected" as never, { stat_rate: "sensor.power" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,317 +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);
|
||||
|
||||
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,13 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
classifyLogbookEntry,
|
||||
entityDisplay,
|
||||
computeLogbookCause,
|
||||
computeLogbookGlyph,
|
||||
findPreviousState,
|
||||
isSameLogbookEntry,
|
||||
} from "../../../src/panels/logbook/logbook-entry-model";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -244,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");
|
||||
@@ -406,170 +403,3 @@ describe("computeLogbookItem", () => {
|
||||
expect(model.value).toEqual({ text: "Ran", type: "state" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPreviousState", () => {
|
||||
const entries: LogbookEntry[] = [
|
||||
entry({ when: 5, entity_id: "light.x", state: "on" }),
|
||||
entry({ when: 4, entity_id: "sensor.y", state: "42" }),
|
||||
entry({ when: 3, entity_id: "automation.z", domain: "automation" }),
|
||||
entry({ when: 2, entity_id: "light.x", state: "off" }),
|
||||
entry({ when: 1, entity_id: "light.x", state: "on" }),
|
||||
];
|
||||
|
||||
it("returns the nearest older state of the same entity", () => {
|
||||
expect(findPreviousState(entries, 0)).toBe("off");
|
||||
expect(findPreviousState(entries, 3)).toBe("on");
|
||||
});
|
||||
|
||||
it("skips entries of other entities", () => {
|
||||
expect(findPreviousState(entries, 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips state-less entries of the same entity", () => {
|
||||
const list = [
|
||||
entry({ when: 3, entity_id: "automation.z", state: "on" }),
|
||||
entry({ when: 2, entity_id: "automation.z", domain: "automation" }),
|
||||
entry({ when: 1, entity_id: "automation.z", state: "off" }),
|
||||
];
|
||||
expect(findPreviousState(list, 0)).toBe("off");
|
||||
});
|
||||
|
||||
it("returns undefined at the end of the list", () => {
|
||||
expect(findPreviousState(entries, 4)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined without an entity or for an out-of-range index", () => {
|
||||
expect(findPreviousState(entries, 2)).toBeUndefined();
|
||||
expect(findPreviousState(entries, 99)).toBeUndefined();
|
||||
expect(findPreviousState([], 0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,3 +3,4 @@ global.navigator = (global.navigator ?? {}) as any;
|
||||
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
global.__HASS_URL__ = "";
|
||||
|
||||
@@ -9675,10 +9675,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globals@npm:17.7.0":
|
||||
version: 17.7.0
|
||||
resolution: "globals@npm:17.7.0"
|
||||
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
|
||||
"globals@npm:17.8.0":
|
||||
version: 17.8.0
|
||||
resolution: "globals@npm:17.8.0"
|
||||
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10011,7 +10011,7 @@ __metadata:
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.7.0"
|
||||
globals: "npm:17.8.0"
|
||||
gulp: "npm:5.0.1"
|
||||
gulp-brotli: "npm:3.0.0"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
|
||||
Reference in New Issue
Block a user