mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-01 03:45:51 +00:00
Compare commits
82 Commits
fix-issue-53199
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 1454f4d081 | |||
| 1a47c326b2 | |||
| d8e827e08b | |||
| 301c907fa9 | |||
| 1ef3bc94d0 | |||
| ba0367be2f | |||
| c186a31056 | |||
| bf8c92c95e | |||
| 9743117abf | |||
| 5e012973b2 | |||
| ea659f1b33 | |||
| 539803cb5b | |||
| 944d3332d1 | |||
| 4a267d160f | |||
| 7edb9f8164 | |||
| ff814167c6 | |||
| d08d8dde64 | |||
| 5da441ceed | |||
| 22e646a68a | |||
| d0ab20479f | |||
| e42be9f4d6 | |||
| a096f8383d | |||
| a3e59d64fc | |||
| e90e8169cd | |||
| 959a798b5c | |||
| 9c117d446e | |||
| 11913292b1 | |||
| 52f2b5abed | |||
| e658113ef4 | |||
| c35e316ef7 | |||
| 946c40d8a3 | |||
| 7ca7c08090 | |||
| 73865161e7 | |||
| f9d1059ff6 | |||
| 691e77f948 | |||
| 6667d0e57e | |||
| 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
|
||||
@@ -34,16 +34,8 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
@@ -64,12 +56,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
@@ -104,7 +98,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Replaced device selectors
|
||||
subtitle: How device and target selectors surface devices that were split into separate devices
|
||||
---
|
||||
|
||||
A device that used to belong to multiple config entries is split into one
|
||||
device per config entry. The original composite device is removed from the
|
||||
registry, so existing references to it (targets in automations, device
|
||||
selectors) point at a device that no longer exists.
|
||||
|
||||
When a selector holds such a reference, it shows a **replaced** state instead of
|
||||
a plain "not found", and offers to point the reference at the replacement
|
||||
device(s). The candidate replacements are filtered through the selector's own
|
||||
filters, so in practice usually a single device matches:
|
||||
|
||||
- **Target selector** — the replaced device row offers **Replace**, which adds
|
||||
every replacement device that matches the target filters and removes the old
|
||||
reference.
|
||||
- **Device selector** — when exactly one replacement matches, **Replace** swaps
|
||||
to it in one click; when several match, a dialog lets you pick one.
|
||||
|
||||
All samples below reference the removed composite device `old_composite`, which
|
||||
was split into a light device and a switch device.
|
||||
@@ -0,0 +1,318 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { Selector } from "../../../../src/data/selector";
|
||||
import {
|
||||
showDialog,
|
||||
type ShowDialogParams,
|
||||
} from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "../../components/demo-black-white-row";
|
||||
|
||||
// The composite device "old_composite" is intentionally NOT in the registry:
|
||||
// it was split into "device_light" and "device_switch". References to the old
|
||||
// id (targets, device selectors) should surface a "replaced" state.
|
||||
const DEVICES: DeviceRegistryEntry[] = [
|
||||
{
|
||||
area_id: "bedroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_light"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_light",
|
||||
identifiers: [["demo", "light"] as [string, string]],
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Living room lamp",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_switch"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_switch",
|
||||
identifiers: [["demo", "switch"] as [string, string]],
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Garden socket",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
},
|
||||
];
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "light.living_room_lamp",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Living room lamp" },
|
||||
},
|
||||
{
|
||||
entity_id: "switch.garden_socket",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Garden socket" },
|
||||
},
|
||||
];
|
||||
|
||||
// Registry display entries link the demo entities to the split devices so the
|
||||
// pickers can filter split candidates by domain.
|
||||
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
|
||||
"light.living_room_lamp": {
|
||||
entity_id: "light.living_room_lamp",
|
||||
name: "Living room lamp",
|
||||
device_id: "device_light",
|
||||
area_id: "bedroom",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
"switch.garden_socket": {
|
||||
entity_id: "switch.garden_socket",
|
||||
name: "Garden socket",
|
||||
device_id: "device_switch",
|
||||
area_id: "backyard",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
};
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: null,
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: null,
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Maps the removed composite device to the devices that replaced it.
|
||||
const COMPOSITE_SPLITS = {
|
||||
old_composite: {
|
||||
split_ids: ["device_light", "device_switch"],
|
||||
primary_id: "device_light",
|
||||
},
|
||||
};
|
||||
|
||||
interface Sample {
|
||||
name: string;
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
{
|
||||
name: "Target",
|
||||
description:
|
||||
"Migrate adds every replacement device that matches the target filters (here both).",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
|
||||
selector: { device: {} },
|
||||
value: "old_composite",
|
||||
},
|
||||
{
|
||||
name: "Device (filtered to lights, single match)",
|
||||
description:
|
||||
"Only the light device passes the filter, so Replace swaps to it in one click.",
|
||||
selector: { device: { entity: [{ domain: "light" }] } },
|
||||
value: "old_composite",
|
||||
},
|
||||
{
|
||||
name: "Device (multiple)",
|
||||
description: "Each slot resolves independently to a matching replacement.",
|
||||
selector: { device: { multiple: true } },
|
||||
value: ["old_composite"],
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-ha-selector-replaced-device")
|
||||
class DemoHaSelectorReplacedDevice
|
||||
extends LitElement
|
||||
implements ProvideHassElement
|
||||
{
|
||||
@state() public hass!: HomeAssistant;
|
||||
|
||||
private _values = SAMPLES.map((sample) => sample.value);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("config", "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
mockEntityRegistry(hass);
|
||||
mockDeviceRegistry(hass, DEVICES);
|
||||
mockConfigEntries(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
// Provide the demo areas and link the demo entities to the split devices.
|
||||
// Set them directly via updateHass (typed against the real registry types)
|
||||
// instead of the area stub, whose demo-specific type differs.
|
||||
const areas: Record<string, AreaRegistryEntry> = {};
|
||||
AREAS.forEach((area) => {
|
||||
areas[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
|
||||
hass.mockWS(
|
||||
"config/device_registry/list_composite_splits",
|
||||
() => COMPOSITE_SPLITS
|
||||
);
|
||||
hass.mockWS("extract_from_target", () => ({
|
||||
referenced_entities: [],
|
||||
referenced_devices: [],
|
||||
referenced_areas: [],
|
||||
}));
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
e.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${SAMPLES.map(
|
||||
(sample, idx) => html`
|
||||
<demo-black-white-row .title=${sample.name}>
|
||||
${["light", "dark"].map(
|
||||
(slot) => html`
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
</demo-black-white-row>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleValueChanged(ev) {
|
||||
const idx = ev.target.sampleIdx;
|
||||
this._values[idx] = ev.detail.value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-settings-row {
|
||||
--settings-row-content-width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.0"
|
||||
version = "20260729.3"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -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,52 @@
|
||||
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
|
||||
|
||||
const HOME_ASSISTANT_SCHEME = "homeassistant://";
|
||||
|
||||
/**
|
||||
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
|
||||
* Only absolute `http:` and `https:` URLs pass, the same check
|
||||
* `ha-attribute-value` already applies to attribute links.
|
||||
*
|
||||
* Use for every URL that reaches the frontend as data — from an integration
|
||||
* manifest, an add-on, a config flow or an entity attribute — so that a
|
||||
* `javascript:` URI can never become a clickable link.
|
||||
*/
|
||||
export const sanitizeHttpUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined => {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return protocol === "http:" || protocol === "https:" ? url : undefined;
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
|
||||
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
|
||||
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
|
||||
|
||||
/**
|
||||
* Turns a `homeassistant://` deep link into an in-app path, or returns
|
||||
* `undefined` when it does not point inside the frontend. Rewriting the scheme
|
||||
* on its own is not enough: `homeassistant:///example.com` would become
|
||||
* `//example.com`, which resolves to another origin.
|
||||
*/
|
||||
export const homeAssistantUrlToPath = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url)
|
||||
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Sanitizes a URL that may be either an external link or a `homeassistant://`
|
||||
* deep link, returning something safe to bind to an `href`.
|
||||
*/
|
||||
export const sanitizeLinkUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { mdiDevices } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { 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";
|
||||
import "../list/ha-list-base";
|
||||
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
|
||||
|
||||
@customElement("dialog-device-replaced")
|
||||
export class DialogDeviceReplaced
|
||||
extends LitElement
|
||||
implements HassDialog<DeviceReplacedDialogParams>
|
||||
{
|
||||
@state() private _params?: DeviceReplacedDialogParams;
|
||||
|
||||
@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 {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _pick(ev: Event): void {
|
||||
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
|
||||
(HTMLElement & { deviceId?: string }) | null;
|
||||
if (!item?.deviceId) {
|
||||
return;
|
||||
}
|
||||
this._params?.onResolved(item.deviceId);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _items = memoizeOne(
|
||||
(
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
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,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
domain: configEntry?.domain,
|
||||
domainName: configEntry
|
||||
? domainToName(this.hass.localize, configEntry.domain)
|
||||
: undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.title"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<p class="description">
|
||||
${this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.description"
|
||||
)}
|
||||
</p>
|
||||
<ha-list-base @click=${this._pick}>
|
||||
${this._items(
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
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}>
|
||||
${
|
||||
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>
|
||||
${
|
||||
supportingText
|
||||
? html`<span slot="supporting-text"
|
||||
>${supportingText}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
})}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--ha-row-item-padding-inline: var(--ha-space-6);
|
||||
}
|
||||
.description {
|
||||
margin: 0;
|
||||
padding: 0 var(--ha-space-6) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-device-replaced": DialogDeviceReplaced;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -201,12 +202,22 @@ export abstract class HaDeviceAutomationPicker<
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the value.
|
||||
// If there is no value, or if we have changed the device ID, reset the
|
||||
// value. When the device changed (for example after replacing a removed
|
||||
// device), try to keep the same automation type/subtype on the new device
|
||||
// before falling back to the first available automation.
|
||||
if (!this.value || this.value.device_id !== this.deviceId) {
|
||||
const equivalent =
|
||||
this.value && this.deviceId
|
||||
? this._automations.find((automation) =>
|
||||
deviceAutomationsSimilar(automation, this.value!)
|
||||
)
|
||||
: undefined;
|
||||
this._setValue(
|
||||
this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
);
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -13,12 +14,20 @@ import {
|
||||
getDevices,
|
||||
type DevicePickerItem,
|
||||
} from "../../data/device/device_picker";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-svg-icon";
|
||||
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
device: DeviceRegistryEntry
|
||||
@@ -95,6 +104,10 @@ export class HaDevicePicker extends LitElement {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
_devices: HomeAssistant["devices"],
|
||||
@@ -123,6 +136,29 @@ export class HaDevicePicker extends LitElement {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.value ||
|
||||
this._compositeSplits !== undefined ||
|
||||
this._loadingCompositeSplits
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
|
||||
const devicesChanged =
|
||||
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
|
||||
if (
|
||||
(changedProperties.has("value") || devicesChanged) &&
|
||||
!this.hass.devices[this.value]
|
||||
) {
|
||||
// The selected device is not in the registry; it might be a legacy
|
||||
// composite device that was split into separate devices. Fetch the
|
||||
// split map so we can offer to replace the reference.
|
||||
this._loadCompositeSplits();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
@@ -130,6 +166,43 @@ export class HaDevicePicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _getReplacement = memoizeOne(
|
||||
(
|
||||
value: string | undefined,
|
||||
_devices: HomeAssistant["devices"],
|
||||
compositeSplits: DeviceCompositeSplits | undefined,
|
||||
items: (DevicePickerItem | string)[]
|
||||
) => {
|
||||
if (!value || !compositeSplits || this.hass.devices[value]) {
|
||||
return undefined;
|
||||
}
|
||||
const split = compositeSplits[value];
|
||||
if (!split) {
|
||||
return undefined;
|
||||
}
|
||||
// Keep only the split devices that pass this picker's filters. In
|
||||
// practice usually exactly one of the split devices matches.
|
||||
const selectableIds = new Set(
|
||||
items
|
||||
.filter((item): item is DevicePickerItem => typeof item !== "string")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
|
||||
return { candidates, primaryId: split.primary_id };
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getDevicesMemoized(
|
||||
this.hass.devices,
|
||||
@@ -144,49 +217,66 @@ export class HaDevicePicker extends LitElement {
|
||||
);
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
replacementName: string | undefined
|
||||
) =>
|
||||
(value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const primary = deviceName;
|
||||
const secondary = areaName;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntriesLookup[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${
|
||||
configEntry
|
||||
? html`<img
|
||||
if (!device) {
|
||||
// When the device was replaced and a replacement is available, show
|
||||
// the replacement device's name. Otherwise fall back to the normal
|
||||
// "not found" display of the raw id.
|
||||
if (replacementName) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: configEntry.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: nothing
|
||||
style="color: var(--warning-color)"
|
||||
.path=${mdiAlertOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${replacementName}</span>
|
||||
`;
|
||||
}
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
<span slot="headline">${primary}</span>
|
||||
<span slot="supporting-text">${secondary}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const primary = deviceName;
|
||||
const secondary = areaName;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntriesLookup[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${
|
||||
configEntry
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: configEntry.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">${primary}</span>
|
||||
<span slot="supporting-text">${secondary}</span>
|
||||
`;
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
@@ -235,7 +325,39 @@ export class HaDevicePicker extends LitElement {
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.device-picker.placeholder");
|
||||
|
||||
const valueRenderer = this._valueRenderer(this._configEntryLookup);
|
||||
// Only resolve a replacement (which needs the full item list) when the
|
||||
// value is a missing device that we know was replaced, to avoid computing
|
||||
// the item list on every render for the common case.
|
||||
const replacement =
|
||||
this.value &&
|
||||
!this.hass.devices[this.value] &&
|
||||
this._compositeSplits?.[this.value]
|
||||
? this._getReplacement(
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Only treat the value as "replaced" when there is an available
|
||||
// replacement device; otherwise fall back to normal "not found" behavior.
|
||||
const canReplace = !!replacement?.candidates.length;
|
||||
const replacementName = canReplace
|
||||
? computeDeviceName(
|
||||
this.hass.devices[
|
||||
replacement!.primaryId &&
|
||||
replacement!.candidates.includes(replacement!.primaryId)
|
||||
? replacement!.primaryId
|
||||
: replacement!.candidates[0]
|
||||
]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const valueRenderer = this._valueRenderer(
|
||||
this._configEntryLookup,
|
||||
replacementName
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
@@ -256,15 +378,84 @@ export class HaDevicePicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.device-picker.unknown"
|
||||
)}
|
||||
.unknownItemText=${
|
||||
replacement?.candidates.length
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_count",
|
||||
{ count: replacement.candidates.length }
|
||||
)
|
||||
: this.hass.localize("ui.components.device-picker.unknown")
|
||||
}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderReplacedAlert(replacement: {
|
||||
candidates: string[];
|
||||
primaryId: string | null;
|
||||
}) {
|
||||
const { candidates } = replacement;
|
||||
|
||||
const replacementName =
|
||||
candidates.length === 1
|
||||
? computeDeviceName(this.hass.devices[candidates[0]])
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
@click=${this._handleReplace}
|
||||
>
|
||||
${this.hass.localize("ui.components.device-picker.replace_device")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleReplace = () => {
|
||||
const replacement = this._getReplacement(
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
}
|
||||
const { candidates, primaryId } = replacement;
|
||||
if (candidates.length === 1) {
|
||||
this._setValue(candidates[0]);
|
||||
return;
|
||||
}
|
||||
showDeviceReplacedDialog(this, {
|
||||
originalDeviceId: this.value!,
|
||||
candidates,
|
||||
primaryId,
|
||||
onResolved: (deviceId) => this._setValue(deviceId),
|
||||
});
|
||||
};
|
||||
|
||||
private _setValue(value: string) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
@@ -281,6 +472,13 @@ export class HaDevicePicker extends LitElement {
|
||||
this.hass.localize("ui.components.device-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
export interface DeviceReplacedDialogParams {
|
||||
/** The removed composite device that is being referenced. */
|
||||
originalDeviceId: string;
|
||||
/** The split devices the reference can be pointed at. */
|
||||
candidates: string[];
|
||||
/** The split device that took over the composite's primary config entry. */
|
||||
primaryId: string | null;
|
||||
/** Called with the device the user picked as replacement. */
|
||||
onResolved: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export const showDeviceReplacedDialog = (
|
||||
element: HTMLElement,
|
||||
params: DeviceReplacedDialogParams
|
||||
) =>
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-device-replaced",
|
||||
dialogImport: () => import("./dialog-device-replaced"),
|
||||
dialogParams: params,
|
||||
});
|
||||
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "error-message" }) public errorMessage?: string;
|
||||
|
||||
@property({ type: Boolean }) public invalid = false;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ attribute: "statistic-types" })
|
||||
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.invalid=${this.invalid}
|
||||
.errorMessage=${this.errorMessage}
|
||||
.label=${this.label}
|
||||
use-top-label
|
||||
.placeholder=${placeholder}
|
||||
|
||||
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button-toolbar";
|
||||
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
|
||||
import type {
|
||||
HaIconButtonToolbar,
|
||||
HaIconButtonToolbarItem,
|
||||
} from "./ha-icon-button-toolbar";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
@property({ type: Boolean, attribute: "has-test" })
|
||||
public hasTest = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
|
||||
|
||||
@property({ attribute: false }) public testing = false;
|
||||
|
||||
@property({ type: String }) public placeholder?: string;
|
||||
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
changedProps.has("_canCopy") ||
|
||||
changedProps.has("_canUndo") ||
|
||||
changedProps.has("_canRedo") ||
|
||||
changedProps.has("testing")
|
||||
changedProps.has("testing") ||
|
||||
changedProps.has("toolbarItems")
|
||||
) {
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
this._editorToolbar.items = [
|
||||
...(this.toolbarItems ?? []),
|
||||
...(this.hasTest && !this._isFullscreen
|
||||
? [
|
||||
{
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _renderHelper() {
|
||||
const showError = this.invalid && this.errorMessage;
|
||||
const showHelper = !showError && this.helper;
|
||||
|
||||
if (!showError && !showHelper) {
|
||||
if (!showError && !this.helper) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${showError ? this.errorMessage : this.helper}
|
||||
${
|
||||
showError
|
||||
? html`<span class="error">${this.errorMessage}</span> ${
|
||||
this.helper
|
||||
? html`<span class="helper">${this.helper}</span>`
|
||||
: nothing
|
||||
}`
|
||||
: this.helper
|
||||
}
|
||||
</ha-input-helper-text>`;
|
||||
}
|
||||
|
||||
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
:host([invalid]) ha-input-helper-text {
|
||||
color: var(--mdc-theme-error, var(--error-color, #b00020));
|
||||
}
|
||||
ha-input-helper-text .error,
|
||||
ha-input-helper-text .helper {
|
||||
display: block;
|
||||
}
|
||||
ha-input-helper-text .helper {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
|
||||
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
|
||||
)) ||
|
||||
serviceData?.description;
|
||||
|
||||
const documentationLink =
|
||||
this._manifest?.is_built_in && this._value?.action
|
||||
? documentationUrl(this.hass, `/actions/${this._value.action}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const targetSelector =
|
||||
serviceData && "target" in serviceData
|
||||
? this._targetSelector(
|
||||
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : ""}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html` <a
|
||||
href=${
|
||||
this._manifest.is_built_in && this._value?.action
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/actions/${this._value.action}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-split-panel")
|
||||
export class HaSplitPanel extends SplitPanel {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
SplitPanel.styles,
|
||||
css`
|
||||
:host {
|
||||
--divider-width: var(--ha-split-panel-divider-width, 2px);
|
||||
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
|
||||
--min: var(--ha-split-panel-min, 0);
|
||||
--max: var(--ha-split-panel-max, 100%);
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: var(--divider-color);
|
||||
transition: background-color var(--ha-animation-duration-fast)
|
||||
ease-out;
|
||||
}
|
||||
|
||||
/* Grip affordance so the divider reads as draggable. The divider
|
||||
already centers its children via flexbox, so keep this in flow.
|
||||
Consumers slotting their own divider handle can hide it with
|
||||
--ha-split-panel-grip-display: none. */
|
||||
.divider::before {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: var(--ha-space-8);
|
||||
display: var(--ha-split-panel-grip-display, block);
|
||||
border-radius: var(--ha-border-radius-pill, 9999px);
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
/* In vertical orientation the divider is horizontal, so the grip pill
|
||||
lies flat instead of standing upright. */
|
||||
:host([orientation="vertical"]) .divider::before {
|
||||
width: var(--ha-space-8);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host(:not([disabled])) .divider:hover {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
:host(:not([disabled])) .divider:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:not([disabled])) .divider:focus-visible {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-split-panel": HaSplitPanel;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
getDevices,
|
||||
type DevicePickerItem,
|
||||
} from "../data/device/device_picker";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import {
|
||||
entityComboBoxKeys,
|
||||
@@ -122,6 +126,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
@@ -211,6 +219,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
const devicesChanged =
|
||||
changedProps.has("hass") &&
|
||||
this.hass.devices !== changedProps.get("hass")?.devices;
|
||||
if (
|
||||
(changedProps.has("value") || devicesChanged) &&
|
||||
this.hass &&
|
||||
this._compositeSplits === undefined &&
|
||||
!this._loadingCompositeSplits &&
|
||||
this.value?.device_id &&
|
||||
ensureArray(this.value.device_id).some(
|
||||
(deviceId) => !this.hass.devices[deviceId]
|
||||
)
|
||||
) {
|
||||
// A referenced device is missing from the registry; it might be a legacy
|
||||
// composite device that was split. Fetch the split map to offer a fix.
|
||||
this._loadCompositeSplits();
|
||||
}
|
||||
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProps.has("hass") &&
|
||||
@@ -297,6 +323,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${device_id}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
@@ -390,6 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
<ha-target-picker-item-group
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@replace-target-item=${this._handleReplace}
|
||||
@migrate-target-item=${this._handleMigrate}
|
||||
type="device"
|
||||
.hass=${this.hass}
|
||||
.items=${{ device: deviceIds }}
|
||||
@@ -398,6 +426,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
>
|
||||
</ha-target-picker-item-group>
|
||||
`
|
||||
@@ -1175,6 +1204,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMigrate(
|
||||
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
|
||||
) {
|
||||
const { id, replacements } = ev.detail;
|
||||
let value = this._removeItem(this.value, "device", id);
|
||||
for (const replacement of replacements) {
|
||||
value = this._addTargetToValue(value, {
|
||||
type: "device",
|
||||
id: replacement,
|
||||
});
|
||||
}
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _renderRow = (
|
||||
item:
|
||||
| PickerComboBoxItem
|
||||
@@ -1357,6 +1411,7 @@ declare global {
|
||||
"remove-target-item": TargetItem;
|
||||
"expand-target-item": TargetItem;
|
||||
"replace-target-item": TargetItem;
|
||||
"migrate-target-item": { id: string; replacements: string[] };
|
||||
"remove-target-group": string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,9 @@ export class HaThemeSettings extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
browseMediaPlayer,
|
||||
BROWSER_PLAYER,
|
||||
MediaClassBrowserSettings,
|
||||
searchMediaPlayer,
|
||||
} from "../../data/media-player";
|
||||
import {
|
||||
browseLocalMediaPlayer,
|
||||
isManualMediaSourceContentId,
|
||||
isMediaSourceContentId,
|
||||
MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
searchMedia,
|
||||
} from "../../data/media_source";
|
||||
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
const mediaFilterClasses = this._mediaClassFilter.length
|
||||
? this._mediaClassFilter
|
||||
: undefined;
|
||||
// A player's tree can embed media sources, which resolve their own searches;
|
||||
// everything else in it uses integration specific ids only the entity knows.
|
||||
const searchEntityId =
|
||||
this.entityId &&
|
||||
this.entityId !== BROWSER_PLAYER &&
|
||||
!isMediaSourceContentId(navigateId.media_content_id ?? "")
|
||||
? this.entityId
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const { result } = await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
const { result } = searchEntityId
|
||||
? await searchMediaPlayer(
|
||||
this.hass,
|
||||
searchEntityId,
|
||||
searchQuery,
|
||||
navigateId.media_content_id,
|
||||
navigateId.media_content_type,
|
||||
mediaFilterClasses
|
||||
)
|
||||
: await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
// Ignore the response if a newer search started or we navigated away
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { TargetType, TargetTypeFloorless } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -52,6 +53,9 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
public primaryEntitiesOnly?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
protected render() {
|
||||
let count = 0;
|
||||
Object.values(this.items).forEach((items) => {
|
||||
@@ -87,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
.compositeSplits=${this.compositeSplits}
|
||||
></ha-target-picker-item-row>`
|
||||
)
|
||||
: nothing
|
||||
|
||||
@@ -34,7 +34,10 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../../data/area/area_registry";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import type {
|
||||
DeviceCompositeSplits,
|
||||
DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { FloorRegistryEntry } from "../../data/floor_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
@@ -54,6 +57,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-button";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-svg-icon";
|
||||
@@ -108,6 +112,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
public primaryEntitiesOnly?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
@@ -128,6 +135,15 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const replacement =
|
||||
this.type === "device" && notFound
|
||||
? this._getReplacement(this.itemId)
|
||||
: undefined;
|
||||
// Only surface the "replaced" state when there is at least one available
|
||||
// replacement device to migrate to. If every replacement device was
|
||||
// deleted (or filtered out), fall back to the plain "not found" state.
|
||||
const canMigrate = !!replacement?.candidates.length;
|
||||
|
||||
const showEntities = this.type !== "entity" && !notFound;
|
||||
|
||||
const entries = this.parentEntries || this._entries;
|
||||
@@ -174,15 +190,20 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div slot="headline">${name}</div>
|
||||
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
|
||||
${
|
||||
notFound || (context && !this.hideContext)
|
||||
? html`<span slot="supporting-text"
|
||||
>${
|
||||
notFound
|
||||
? this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
? canMigrate
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
: context
|
||||
}</span
|
||||
>`
|
||||
@@ -229,6 +250,24 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
canMigrate
|
||||
? html`
|
||||
<ha-button
|
||||
class="migrate"
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
variant="warning"
|
||||
size="s"
|
||||
@click=${this._migrate}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.replace_device"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!this.expand && !this.subEntry
|
||||
? html`
|
||||
@@ -707,6 +746,51 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Returns the split devices that replaced a removed composite device and
|
||||
// pass this row's filters, or undefined if the item is not a replaced device.
|
||||
private _getReplacement(item: string) {
|
||||
const split = this.compositeSplits?.[item];
|
||||
if (!split || this.hass.devices[item]) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates = split.split_ids.filter((id) => {
|
||||
const device = this.hass.devices[id];
|
||||
return (
|
||||
device &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
);
|
||||
});
|
||||
// Display the replaced reference using the primary replacement device's
|
||||
// name instead of the removed composite device id. Fall back to the first
|
||||
// available candidate if the primary device itself was deleted.
|
||||
const nameDevice =
|
||||
(split.primary_id && this.hass.devices[split.primary_id]) ||
|
||||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
|
||||
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
|
||||
return { candidates, name };
|
||||
}
|
||||
|
||||
private _migrate = (ev: MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const replacement = this._getReplacement(this.itemId);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "migrate-target-item", {
|
||||
id: this.itemId,
|
||||
replacements: replacement.candidates,
|
||||
});
|
||||
};
|
||||
|
||||
private _openDetails(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
showTargetDetailsDialog(this, {
|
||||
@@ -746,6 +830,11 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
|
||||
.migrate {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.replaceable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/tag/tag";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
@@ -19,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
@@ -39,6 +42,9 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
@@ -51,38 +57,74 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const split =
|
||||
this.type === "device" && !this.hass.devices?.[this.itemId]
|
||||
? this.compositeSplits?.[this.itemId]
|
||||
: undefined;
|
||||
// Show the replaced reference using the primary replacement device's name,
|
||||
// falling back to the first still-existing split device if the primary
|
||||
// device itself was deleted. If no replacement device exists at all, fall
|
||||
// back to the normal "not found" display.
|
||||
const replacementDevice = split
|
||||
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
|
||||
split.split_ids
|
||||
.map((id) => this.hass.devices?.[id])
|
||||
.find((device) => device)
|
||||
: undefined;
|
||||
const replaced = !!replacementDevice;
|
||||
const replacedName = replacementDevice
|
||||
? computeDeviceNameDisplay(
|
||||
replacementDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<wa-tag
|
||||
pill
|
||||
with-remove
|
||||
class=${this.type}
|
||||
class=${classMap({ [this.type]: true, replaced })}
|
||||
style=${color ? `--color: rgb(${color});` : ""}
|
||||
@wa-remove=${this._removeItem}
|
||||
>
|
||||
<div class="icon">
|
||||
${
|
||||
iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
replaced
|
||||
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
|
||||
: iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="name"> ${name} </span>
|
||||
<span class="name">
|
||||
${
|
||||
replaced
|
||||
? replacedName ||
|
||||
this.hass.localize(
|
||||
"ui.components.target-picker.replaced_device"
|
||||
)
|
||||
: name
|
||||
}
|
||||
</span>
|
||||
${
|
||||
this.type === "entity"
|
||||
this.type === "entity" || replaced
|
||||
? nothing
|
||||
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
@@ -125,7 +167,7 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device.primary_config_entry) {
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
@@ -240,6 +282,11 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
--background-color: var(--color);
|
||||
--icon-primary-color: var(--primary-text-color);
|
||||
}
|
||||
wa-tag.replaced {
|
||||
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
|
||||
--background-color: var(--warning-color);
|
||||
color: var(--ha-color-on-warning-normal, var(--warning-color));
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public vertical = false;
|
||||
|
||||
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
|
||||
@property({ type: Boolean, attribute: "fixed-info-height" })
|
||||
public fixedInfoHeight = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public interactive = false;
|
||||
|
||||
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const contentClasses = { vertical: this.vertical };
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
padding: 10px var(--ha-space-2);
|
||||
}
|
||||
.vertical.fixed-info-height {
|
||||
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
|
||||
gap: 2px;
|
||||
--ha-tile-info-gap: 2px;
|
||||
--ha-tile-info-primary-line-height: var(--ha-space-4);
|
||||
--ha-tile-info-primary-min-height: var(--ha-space-8);
|
||||
--ha-tile-info-min-height: var(--ha-space-12);
|
||||
}
|
||||
.vertical ::slotted([slot="info"]) {
|
||||
width: 100%;
|
||||
|
||||
@@ -15,6 +15,11 @@ import { customElement, property } from "lit/decorators";
|
||||
*
|
||||
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
|
||||
*
|
||||
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
|
||||
*
|
||||
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
|
||||
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
|
||||
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of the number of rendered lines. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
|
||||
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
|
||||
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
|
||||
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
|
||||
@@ -39,7 +44,7 @@ export class HaTileInfo extends LitElement {
|
||||
return html`
|
||||
<div class="info">
|
||||
<slot name="primary" class="primary">
|
||||
<span>${this.primary}</span>
|
||||
<span part="primary">${this.primary}</span>
|
||||
</slot>
|
||||
${
|
||||
this.secondaryLoading
|
||||
@@ -59,6 +64,8 @@ export class HaTileInfo extends LitElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
--tile-info-gap: var(--ha-tile-info-gap, 0);
|
||||
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
|
||||
--tile-info-primary-font-size: var(
|
||||
--ha-tile-info-primary-font-size,
|
||||
var(--ha-font-size-m)
|
||||
@@ -71,6 +78,10 @@ export class HaTileInfo extends LitElement {
|
||||
--ha-tile-info-primary-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
--tile-info-primary-min-height: var(
|
||||
--ha-tile-info-primary-min-height,
|
||||
auto
|
||||
);
|
||||
--tile-info-primary-letter-spacing: var(
|
||||
--ha-tile-info-primary-letter-spacing,
|
||||
0.1px
|
||||
@@ -106,28 +117,38 @@ export class HaTileInfo extends LitElement {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--tile-info-gap);
|
||||
min-height: var(--tile-info-min-height);
|
||||
}
|
||||
span,
|
||||
::slotted(*) {
|
||||
.primary span,
|
||||
::slotted([slot="primary"]),
|
||||
.secondary span,
|
||||
::slotted([slot="secondary"]) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-primary-font-size);
|
||||
font-weight: var(--tile-info-primary-font-weight);
|
||||
line-height: var(--tile-info-primary-line-height);
|
||||
letter-spacing: var(--tile-info-primary-letter-spacing);
|
||||
color: var(--tile-info-primary-color);
|
||||
min-height: var(--tile-info-primary-min-height);
|
||||
}
|
||||
.secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-secondary-font-size);
|
||||
font-weight: var(--tile-info-secondary-font-weight);
|
||||
line-height: var(--tile-info-secondary-line-height);
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
|
||||
+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;
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { HaFormSchema } from "../../components/ha-form/types";
|
||||
import type { CallWS } from "../../types";
|
||||
import type { CallWS, HomeAssistant } from "../../types";
|
||||
import type { BaseTrigger } from "../automation";
|
||||
import { migrateAutomationTrigger } from "../automation";
|
||||
import type { DeviceCompositeSplits } from "./device_registry";
|
||||
import type { EntityRegistryEntry } from "../entity/entity_registry";
|
||||
import {
|
||||
entityRegistryByEntityId,
|
||||
@@ -158,6 +159,51 @@ export const deviceAutomationsEqual = (
|
||||
return true;
|
||||
};
|
||||
|
||||
// Decides how a device automation editor should handle its referenced device.
|
||||
// A missing device that was replaced by a split device stays editable so the
|
||||
// device picker can offer to fix the reference; a genuinely unknown device
|
||||
// cannot be edited visually. Returns "loading" while the split map is unknown.
|
||||
export const deviceAutomationEditorMode = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string | undefined,
|
||||
compositeSplits: DeviceCompositeSplits | undefined
|
||||
): "editable" | "loading" | "unknown-device" => {
|
||||
if (!deviceId || deviceId in hass.devices) {
|
||||
return "editable";
|
||||
}
|
||||
if (compositeSplits === undefined) {
|
||||
return "loading";
|
||||
}
|
||||
// Only editable if at least one of the split (replacement) devices still
|
||||
// exists; otherwise the reference is stale and cannot be fixed here.
|
||||
const split = compositeSplits[deviceId];
|
||||
return split?.split_ids.some((id) => id in hass.devices)
|
||||
? "editable"
|
||||
: "unknown-device";
|
||||
};
|
||||
|
||||
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
|
||||
// automation can be matched to the equivalent one on a different device (for
|
||||
// example when a referenced device was replaced by a split device).
|
||||
export const deviceAutomationsSimilar = (
|
||||
a: DeviceAutomation,
|
||||
b: DeviceAutomation
|
||||
) => {
|
||||
if (typeof a !== typeof b) {
|
||||
return false;
|
||||
}
|
||||
return deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => {
|
||||
const inA = property in a;
|
||||
const inB = property in b;
|
||||
if (!inA && !inB) {
|
||||
return true;
|
||||
}
|
||||
return Object.is(a[property], b[property]);
|
||||
});
|
||||
};
|
||||
|
||||
const compareEntityIdWithEntityRegId = (
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
entityIdA?: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -54,6 +55,69 @@ export interface DeviceRegistryEntryMutableParams {
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes how a legacy composite device (that lived on multiple config
|
||||
* entries) was split into separate devices. The composite device no longer
|
||||
* exists in the registry; references to it (in automations, targets, ...) now
|
||||
* need to point at one or more of the split devices instead.
|
||||
*/
|
||||
export interface DeviceCompositeSplit {
|
||||
/** Ids of the devices that replaced the composite device. */
|
||||
split_ids: string[];
|
||||
/** The split device that took over the composite's primary config entry. */
|
||||
primary_id: string | null;
|
||||
}
|
||||
|
||||
/** Map of removed composite device id -> its split information. */
|
||||
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
|
||||
|
||||
// The composite split migration in core is a one-time operation, so the split
|
||||
// map is static for the lifetime of the connection. Cache the request per
|
||||
// connection so it is fetched once and shared across all pickers, instead of
|
||||
// being requested again by every device/target picker.
|
||||
const compositeSplitsCache = new WeakMap<
|
||||
Connection,
|
||||
Promise<DeviceCompositeSplits>
|
||||
>();
|
||||
|
||||
export const fetchDeviceCompositeSplits = (
|
||||
hass: Pick<HomeAssistant, "connection" | "callWS">
|
||||
): Promise<DeviceCompositeSplits> => {
|
||||
const conn = hass.connection;
|
||||
|
||||
let request = compositeSplitsCache.get(conn);
|
||||
if (!request) {
|
||||
request = hass
|
||||
.callWS<DeviceCompositeSplits>({
|
||||
type: "config/device_registry/list_composite_splits",
|
||||
})
|
||||
.catch((err) => {
|
||||
// Don't cache failures so the next caller retries.
|
||||
compositeSplitsCache.delete(conn);
|
||||
throw err;
|
||||
});
|
||||
compositeSplitsCache.set(conn, request);
|
||||
}
|
||||
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);
|
||||
+27
-5
@@ -1,6 +1,7 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
|
||||
domain: string;
|
||||
name: string;
|
||||
config_flow: boolean;
|
||||
documentation: string;
|
||||
documentation?: string;
|
||||
issue_tracker?: string;
|
||||
dependencies?: string[];
|
||||
after_dependencies?: string[];
|
||||
@@ -78,11 +79,27 @@ export enum LogSeverity {
|
||||
|
||||
export type IntegrationLogPersistance = "none" | "once" | "permanent";
|
||||
|
||||
/**
|
||||
* A custom integration supplies its own manifest, so its URLs are untrusted
|
||||
* input. Strip them here, where manifests enter the frontend, so no consumer can
|
||||
* turn one into a link that runs script.
|
||||
*/
|
||||
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
|
||||
manifest: T
|
||||
): T =>
|
||||
manifest
|
||||
? ({
|
||||
...manifest,
|
||||
documentation: sanitizeHttpUrl(manifest.documentation),
|
||||
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
|
||||
} as T)
|
||||
: manifest;
|
||||
|
||||
export const integrationIssuesUrl = (
|
||||
domain: string,
|
||||
manifest: IntegrationManifest
|
||||
) =>
|
||||
manifest.issue_tracker ||
|
||||
sanitizeHttpUrl(manifest.issue_tracker) ||
|
||||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
|
||||
|
||||
export const domainToName = (
|
||||
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
|
||||
if (integrations) {
|
||||
params.integrations = integrations;
|
||||
}
|
||||
return hass.callWS<IntegrationManifest[]>(params);
|
||||
return hass
|
||||
.callWS<IntegrationManifest[]>(params)
|
||||
.then((manifests) => manifests.map(sanitizeManifest));
|
||||
};
|
||||
|
||||
export const fetchIntegrationManifestsCollection = async (
|
||||
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
});
|
||||
const manifests: DomainManifestLookup = {};
|
||||
for (const manifest of fetched) {
|
||||
manifests[manifest.domain] = manifest;
|
||||
manifests[manifest.domain] = sanitizeManifest(manifest);
|
||||
}
|
||||
setValue(manifests);
|
||||
// One-time fetch — nothing to unsubscribe from
|
||||
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
export const fetchIntegrationManifest = (
|
||||
hass: HomeAssistant,
|
||||
integration: string
|
||||
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
|
||||
) =>
|
||||
hass
|
||||
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
|
||||
.then(sanitizeManifest);
|
||||
|
||||
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
|
||||
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
|
||||
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
|
||||
media_content_type: mediaContentType,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMediaPlayer = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
searchQuery: string,
|
||||
mediaContentId?: string,
|
||||
mediaContentType?: string,
|
||||
mediaFilterClasses?: string[]
|
||||
): Promise<SearchMediaResult> =>
|
||||
hass.callWS<SearchMediaResult>({
|
||||
type: "media_player/search_media",
|
||||
entity_id: entityId,
|
||||
search_query: searchQuery,
|
||||
// the backend requires these two to be passed together, and JSON
|
||||
// serialization drops them both when the current item is the root
|
||||
media_content_id: mediaContentId,
|
||||
media_content_type: mediaContentType,
|
||||
media_filter_classes: mediaFilterClasses,
|
||||
});
|
||||
|
||||
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
|
||||
let progress = stateObj.attributes.media_position!;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem } from "./media-player";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
export interface ResolvedMediaSource {
|
||||
url: string;
|
||||
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
|
||||
media_content_id: mediaContentId,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMedia = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId: string | undefined,
|
||||
|
||||
+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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HomeAssistantApi } from "../types";
|
||||
|
||||
export const fetchSlug = (
|
||||
api: HomeAssistantApi,
|
||||
text: string
|
||||
): Promise<{ slug: string }> =>
|
||||
api.callWS<{ slug: string }>({
|
||||
type: "slugify",
|
||||
text,
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
this._params.manifest?.is_built_in) ||
|
||||
!!this._params.manifest?.documentation;
|
||||
|
||||
const documentationLink = this._params.manifest?.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._params.manifest.domain}`
|
||||
)
|
||||
: this._params.manifest?.documentation;
|
||||
|
||||
const dialogTitle = this._getDialogTitle();
|
||||
const dialogSubtitle = this._getDialogSubtitle();
|
||||
|
||||
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDocumentationLink && !this._loading && this._step
|
||||
showDocumentationLink &&
|
||||
documentationLink &&
|
||||
!this._loading &&
|
||||
this._step
|
||||
? html`
|
||||
<a
|
||||
slot="headerActionItems"
|
||||
class="help"
|
||||
href=${
|
||||
this._params.manifest!.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._params.manifest!.domain}`
|
||||
)
|
||||
: this._params.manifest!.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
case "external":
|
||||
case "external": {
|
||||
const externalUrl = sanitizeHttpUrl(this._step.url);
|
||||
return html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${this._step.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
${
|
||||
externalUrl
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
}
|
||||
case "create_entry": {
|
||||
const devices = this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
|
||||
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
window.open(this.step.url);
|
||||
// Opened without user interaction, so only ever follow an http(s) URL
|
||||
const url = sanitizeHttpUrl(this.step.url);
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -43,12 +43,19 @@ export class DialogHttpPendingConfig
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
// This dialog must only be dismissed through its own footer buttons
|
||||
// (confirm / revert / close). This flag is flipped right before such a
|
||||
// button closes the dialog, so `closeDialog()` can refuse every other
|
||||
// close request (navigation, back button, `closeAllDialogs`, …).
|
||||
private _resolved = false;
|
||||
|
||||
public showDialog(params: HttpPendingConfigDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._resolved = false;
|
||||
this._startCountdown();
|
||||
// The field labels live in the config panel fragment, which is not loaded
|
||||
// yet when this dialog pops up on startup. Load it so the changed-field
|
||||
@@ -57,6 +64,12 @@ export class DialogHttpPendingConfig
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
// Refuse programmatic close requests (navigation, back button,
|
||||
// `closeAllDialogs`) so a pending HTTP config is never left silently
|
||||
// unresolved. The dialog only closes once the user picks a footer action.
|
||||
if (!this._resolved) {
|
||||
return false;
|
||||
}
|
||||
this._open = false;
|
||||
this._stopCountdown();
|
||||
return true;
|
||||
@@ -337,6 +350,9 @@ export class DialogHttpPendingConfig
|
||||
}
|
||||
|
||||
private _notifyResolved(): void {
|
||||
// Mark the dialog as user-resolved so `closeDialog()` is allowed to close
|
||||
// it; every footer action calls this before setting `_open = false`.
|
||||
this._resolved = true;
|
||||
this._params?.onResolved?.();
|
||||
// The form on Settings > System > Network may be mounted and showing
|
||||
// stale state; let it know to refetch.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
|
||||
const createBackupTexts = this._computeCreateBackupTexts();
|
||||
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
|
||||
</div>
|
||||
|
||||
${
|
||||
this.stateObj.attributes.release_url
|
||||
releaseUrl
|
||||
? html`<div class="row">
|
||||
<div class="key">
|
||||
<a
|
||||
href=${this.stateObj.attributes.release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a href=${releaseUrl} target="_blank" rel="noreferrer">
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.release_announcement"
|
||||
)}
|
||||
|
||||
@@ -18,38 +18,15 @@
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<meta name="theme-color" content="{{ theme_color }}" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_style_base.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
::view-transition-group(launch-screen) {
|
||||
animation-duration: var(--ha-animation-duration-normal, 250ms);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-old(launch-screen) {
|
||||
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
height: 100vh;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -61,7 +38,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
view-transition-name: launch-screen;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
z-index: 100;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
@@ -98,7 +76,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
|
||||
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: html`<p>
|
||||
: html`<p class=${classMap({ "loading-text": !this.migration })}>
|
||||
${
|
||||
this.migration
|
||||
? html`<span class="migration-text"
|
||||
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
|
||||
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
|
||||
}</span
|
||||
>`
|
||||
: this.localize?.("ui.init.loading") || "Loading data"
|
||||
: this.localize?.("ui.init.loading") || "Loading..."
|
||||
}
|
||||
</p>`;
|
||||
}
|
||||
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
|
||||
.migration-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.loading-text {
|
||||
opacity: 0.66;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
|
||||
const selectedDomainName = this._params.selectedDomain
|
||||
? domainToName(this.hass.localize, this._domain!)
|
||||
: "";
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._domain}`)
|
||||
: this._manifest?.documentation;
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
|
||||
}
|
||||
)}
|
||||
${
|
||||
this._manifest?.is_built_in ||
|
||||
this._manifest?.documentation
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../../components/chips/ha-assist-chip";
|
||||
import "../../../../../components/chips/ha-chip-set";
|
||||
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
"ui.panel.config.apps.dashboard.visit_app_page",
|
||||
{
|
||||
name: html`<a
|
||||
href=${this._currentAddon.url!}
|
||||
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${getAppDisplayName(
|
||||
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private get _pathWebui(): string | null {
|
||||
const addon = this._currentAddon as HassioAddonDetails;
|
||||
return addon.webui!.replace("[HOST]", document.location.hostname);
|
||||
return (
|
||||
sanitizeHttpUrl(
|
||||
addon.webui!.replace("[HOST]", document.location.hostname)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private get _computeShowWebUI(): boolean | "" | null {
|
||||
|
||||
@@ -13,11 +13,16 @@ import type {
|
||||
DeviceCapabilities,
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceActionCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
localizeExtraFieldsComputeLabelCallback,
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@@ -33,6 +38,10 @@ export class HaDeviceAction extends LitElement {
|
||||
|
||||
@state() private _capabilities?: DeviceCapabilities;
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -59,14 +68,19 @@ export class HaDeviceAction extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
public shouldUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (!changedProperties.has("action")) {
|
||||
return true;
|
||||
public shouldUpdate(_changedProperties: PropertyValues<this>) {
|
||||
const mode = deviceAutomationEditorMode(
|
||||
this.hass,
|
||||
this.action.device_id,
|
||||
this._compositeSplits
|
||||
);
|
||||
if (mode === "loading") {
|
||||
// The device is missing; wait for the composite split map before deciding
|
||||
// whether it is a replaced device (editable) or genuinely unknown (YAML).
|
||||
this._loadCompositeSplits();
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.action.device_id &&
|
||||
!(this.action.device_id in this.hass.devices)
|
||||
) {
|
||||
if (mode === "unknown-device") {
|
||||
fireEvent(
|
||||
this,
|
||||
"ui-mode-not-available",
|
||||
@@ -81,6 +95,20 @@ export class HaDeviceAction extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const deviceId = this._deviceId || this.action.device_id;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,11 +13,16 @@ import type {
|
||||
DeviceCondition,
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceConditionCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
localizeExtraFieldsComputeLabelCallback,
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@@ -33,6 +38,10 @@ export class HaDeviceCondition extends LitElement {
|
||||
|
||||
@state() private _capabilities?: DeviceCapabilities;
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -60,14 +69,19 @@ export class HaDeviceCondition extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
public shouldUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (!changedProperties.has("condition")) {
|
||||
return true;
|
||||
public shouldUpdate(_changedProperties: PropertyValues<this>) {
|
||||
const mode = deviceAutomationEditorMode(
|
||||
this.hass,
|
||||
this.condition.device_id,
|
||||
this._compositeSplits
|
||||
);
|
||||
if (mode === "loading") {
|
||||
// The device is missing; wait for the composite split map before deciding
|
||||
// whether it is a replaced device (editable) or genuinely unknown (YAML).
|
||||
this._loadCompositeSplits();
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.condition.device_id &&
|
||||
!(this.condition.device_id in this.hass.devices)
|
||||
) {
|
||||
if (mode === "unknown-device") {
|
||||
fireEvent(
|
||||
this,
|
||||
"ui-mode-not-available",
|
||||
@@ -82,6 +96,20 @@ export class HaDeviceCondition extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const deviceId = this._deviceId || this.condition.device_id;
|
||||
|
||||
|
||||
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/conditions/${this.condition.condition}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -33,11 +33,16 @@ import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import {
|
||||
apiContext,
|
||||
configEntriesContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
labelsContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../../../../data/device/device_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
|
||||
import {
|
||||
deviceMeetsTargetSelector,
|
||||
@@ -89,9 +94,16 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
private _countCache = new Map<
|
||||
string,
|
||||
Promise<number | undefined> | number | undefined
|
||||
@@ -108,6 +120,43 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
) {
|
||||
this._rerenderCount = true;
|
||||
}
|
||||
|
||||
if (
|
||||
(changedProps.has("target") || changedProps.has("_registries")) &&
|
||||
this._compositeSplits === undefined &&
|
||||
!this._loadingCompositeSplits &&
|
||||
this._hasMissingDevice()
|
||||
) {
|
||||
// A referenced device is missing from the registry; it might be a legacy
|
||||
// composite device that was split. Fetch the split map so we can flag it.
|
||||
this._loadCompositeSplits();
|
||||
}
|
||||
}
|
||||
|
||||
private _hasMissingDevice(): boolean {
|
||||
const deviceIds = this.target?.device_id
|
||||
? ensureArray(this.target.device_id)
|
||||
: [];
|
||||
return deviceIds.some(
|
||||
(id) => !isTemplate(id) && !this._registries?.devices?.[id]
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (!this._api || !this._connection) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits({
|
||||
connection: this._connection.connection,
|
||||
callWS: this._api.callWS,
|
||||
});
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
@@ -342,7 +391,8 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
error = false,
|
||||
targetId?: string,
|
||||
targetType?: string,
|
||||
countTemplate: unknown = nothing
|
||||
countTemplate: unknown = nothing,
|
||||
title?: string
|
||||
) {
|
||||
if (!this.interactive || !targetId || !targetType) {
|
||||
return html`<div
|
||||
@@ -351,6 +401,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
warning,
|
||||
error,
|
||||
})}
|
||||
title=${title ?? nothing}
|
||||
.targetId=${targetId}
|
||||
.targetType=${targetType}
|
||||
.label=${label}
|
||||
@@ -366,6 +417,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
warning,
|
||||
error,
|
||||
})}
|
||||
title=${title ?? nothing}
|
||||
.targetId=${targetId}
|
||||
.targetType=${targetType}
|
||||
.label=${label}
|
||||
@@ -385,6 +437,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
let icon: string | undefined;
|
||||
let label: string;
|
||||
let warning = false;
|
||||
let title: string | undefined;
|
||||
let badgeTargetId: string | undefined = targetId;
|
||||
let badgeTargetType: string | undefined = targetType;
|
||||
let countTemplate: unknown = nothing;
|
||||
@@ -408,17 +461,28 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
const exists = this._checkTargetExists(targetType, targetId);
|
||||
if (!exists) {
|
||||
icon = mdiAlert;
|
||||
label = getTargetText(
|
||||
this._registries,
|
||||
this._states,
|
||||
this._i18n.localize,
|
||||
targetType,
|
||||
targetId,
|
||||
this._getLabel
|
||||
);
|
||||
warning = true;
|
||||
badgeTargetId = undefined;
|
||||
badgeTargetType = undefined;
|
||||
if (targetType === "device" && this._compositeSplits?.[targetId]) {
|
||||
// The device was replaced by one or more split devices; make clear
|
||||
// this reference needs to be updated, distinct from "unknown device".
|
||||
label = this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.device_replaced"
|
||||
);
|
||||
title = this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.device_replaced_description"
|
||||
);
|
||||
} else {
|
||||
label = getTargetText(
|
||||
this._registries,
|
||||
this._states,
|
||||
this._i18n.localize,
|
||||
targetType,
|
||||
targetId,
|
||||
this._getLabel
|
||||
);
|
||||
}
|
||||
} else {
|
||||
label = getTargetText(
|
||||
this._registries,
|
||||
@@ -456,6 +520,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
targetType: badgeTargetType,
|
||||
label,
|
||||
}}
|
||||
title=${title ?? nothing}
|
||||
class=${classMap({
|
||||
warning,
|
||||
})}
|
||||
@@ -470,7 +535,8 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
false,
|
||||
badgeTargetId,
|
||||
badgeTargetType,
|
||||
countTemplate
|
||||
countTemplate,
|
||||
title
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ import type {
|
||||
DeviceTrigger,
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationEditorMode,
|
||||
deviceAutomationsEqual,
|
||||
fetchDeviceTriggerCapabilities,
|
||||
localizeExtraFieldsComputeHelperCallback,
|
||||
localizeExtraFieldsComputeLabelCallback,
|
||||
} from "../../../../../data/device/device_automation";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@@ -35,6 +40,10 @@ export class HaDeviceTrigger extends LitElement {
|
||||
|
||||
@state() private _capabilities?: DeviceCapabilities;
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -64,14 +73,19 @@ export class HaDeviceTrigger extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
public shouldUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return true;
|
||||
public shouldUpdate(_changedProperties: PropertyValues<this>) {
|
||||
const mode = deviceAutomationEditorMode(
|
||||
this.hass,
|
||||
this.trigger.device_id,
|
||||
this._compositeSplits
|
||||
);
|
||||
if (mode === "loading") {
|
||||
// The device is missing; wait for the composite split map before deciding
|
||||
// whether it is a replaced device (editable) or genuinely unknown (YAML).
|
||||
this._loadCompositeSplits();
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.trigger.device_id &&
|
||||
!(this.trigger.device_id in this.hass.devices)
|
||||
) {
|
||||
if (mode === "unknown-device") {
|
||||
fireEvent(
|
||||
this,
|
||||
"ui-mode-not-available",
|
||||
@@ -86,6 +100,20 @@ export class HaDeviceTrigger extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (this._loadingCompositeSplits) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const deviceId = this._deviceId || this.trigger.device_id;
|
||||
|
||||
|
||||
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/triggers/${this.trigger.trigger}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -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,264 @@
|
||||
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 memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
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 { fetchSlug } from "../../../data/ws-slugify";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "./ha-entity-id-format-editor";
|
||||
|
||||
const EXAMPLE_DOMAIN = "sensor";
|
||||
const PREVIEW_DEBOUNCE_MS = 200;
|
||||
|
||||
@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;
|
||||
|
||||
@state() private _preview?: string;
|
||||
|
||||
@state() private _previewError = false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
(changedProps.has("_format") || changedProps.has("_localize")) &&
|
||||
this._format
|
||||
) {
|
||||
if (changedProps.get("_format") === undefined) {
|
||||
this._updatePreview();
|
||||
} else {
|
||||
this._debouncedUpdatePreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _examples = memoizeOne(
|
||||
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
|
||||
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
|
||||
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
|
||||
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
|
||||
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
|
||||
})
|
||||
);
|
||||
|
||||
private _debouncedUpdatePreview = debounce(
|
||||
() => this._updatePreview(),
|
||||
PREVIEW_DEBOUNCE_MS
|
||||
);
|
||||
|
||||
private async _updatePreview() {
|
||||
const examples = this._examples(this._localize);
|
||||
const fullName = this._format!.map((part) => examples[part])
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
try {
|
||||
const { slug } = await fetchSlug(this._api, fullName);
|
||||
this._preview = slug;
|
||||
this._previewError = false;
|
||||
} catch (_err: any) {
|
||||
this._previewError = true;
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
return html`
|
||||
<div class="preview">
|
||||
<span class="preview-label">
|
||||
${this._localize("ui.panel.config.entity_id_format.card.preview")}
|
||||
</span>
|
||||
${
|
||||
this._previewError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.preview_error"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</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;
|
||||
}
|
||||
}
|
||||
@@ -96,12 +96,17 @@ import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import "../../logbook/ha-logbook";
|
||||
import "./device-detail/ha-device-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 +897,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`
|
||||
@@ -1257,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
const deviceActions: DeviceAction[] = [];
|
||||
|
||||
const configurationUrlIsHomeAssistant =
|
||||
device.configuration_url?.startsWith("homeassistant://") || false;
|
||||
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
device.configuration_url
|
||||
);
|
||||
|
||||
const configurationUrl = configurationUrlIsHomeAssistant
|
||||
? device.configuration_url?.replace("homeassistant://", "/")
|
||||
: device.configuration_url;
|
||||
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
|
||||
|
||||
if (configurationUrl) {
|
||||
deviceActions.push({
|
||||
|
||||
@@ -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";
|
||||
@@ -132,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _requiredError(): string {
|
||||
return this.hass.localize("ui.common.error_required");
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<p class="power-section-label">
|
||||
@@ -164,11 +97,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>
|
||||
|
||||
@@ -188,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
|
||||
{ unit: this._powerUnits?.join(", ") || "" }
|
||||
)}
|
||||
required
|
||||
.invalid=${!this.powerConfig.stat_rate}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
@@ -207,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.helper=${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
|
||||
)}
|
||||
required
|
||||
.invalid=${!this.powerConfig.stat_rate_inverted}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
// These two exclude each other, so they keep their clear button
|
||||
// (and therefore no required marker) to stay swappable.
|
||||
this.powerType === "two_sensors"
|
||||
? html`
|
||||
<ha-statistic-picker
|
||||
@@ -226,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
this.powerConfig.stat_rate_to,
|
||||
].filter((id): id is string => Boolean(id))}
|
||||
@value-changed=${this._fromPowerChanged}
|
||||
.invalid=${!this.powerConfig.stat_rate_from}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -239,13 +184,56 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
this.powerConfig.stat_rate_from,
|
||||
].filter((id): id is string => Boolean(id))}
|
||||
@value-changed=${this._toPowerChanged}
|
||||
.invalid=${!this.powerConfig.stat_rate_to}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: 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 +277,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: () =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { round } from "../../../common/number/round";
|
||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/chart/ha-chart-base";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
|
||||
) as ConfigEntry[];
|
||||
boardId = boardData.board!.hassio_board_id;
|
||||
boardName = boardData.name;
|
||||
documentationURL = boardData.url;
|
||||
documentationURL = sanitizeHttpUrl(boardData.url);
|
||||
imageURL = hardwareBrandsUrl(
|
||||
{
|
||||
category: "boards",
|
||||
|
||||
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
|
||||
const newValue = { ...this._item };
|
||||
|
||||
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
|
||||
newValue[day] = [...newValue[day]];
|
||||
newValue[day][index] = {
|
||||
...newValue[day][index],
|
||||
from: value.from,
|
||||
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
|
||||
};
|
||||
|
||||
if (newDay === day) {
|
||||
newValue[day] = [...newValue[day]];
|
||||
newValue[day][index] = event;
|
||||
} else {
|
||||
newValue[day].splice(index, 1);
|
||||
|
||||
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
|
||||
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
this.flow.context.configuration_url
|
||||
);
|
||||
const configurationUrl = sanitizeLinkUrl(
|
||||
this.flow.context.configuration_url
|
||||
);
|
||||
const documentationLink = this.manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
|
||||
: this.manifest?.documentation;
|
||||
return html`
|
||||
<ha-integration-action-card
|
||||
class=${classMap({
|
||||
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
|
||||
)}
|
||||
</ha-button>
|
||||
${
|
||||
this.flow.context.configuration_url || this.manifest || attention
|
||||
configurationUrl || documentationLink || attention
|
||||
? html`<ha-dropdown
|
||||
slot="header-button"
|
||||
placement="bottom-end"
|
||||
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this.flow.context.configuration_url
|
||||
configurationUrl
|
||||
? html`<a
|
||||
href=${this.flow.context.configuration_url.replace(
|
||||
/^homeassistant:\/\//,
|
||||
"/"
|
||||
)}
|
||||
href=${configurationUrl}
|
||||
rel="noreferrer"
|
||||
target=${
|
||||
this.flow.context.configuration_url.startsWith(
|
||||
"homeassistant://"
|
||||
)
|
||||
? "_self"
|
||||
: "_blank"
|
||||
configurationUrlIsHomeAssistant ? "_self" : "_blank"
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item>
|
||||
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this.manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this.manifest.domain}`
|
||||
)
|
||||
: this.manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this.domain
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`
|
||||
<a
|
||||
slot="toolbar-icon"
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
|
||||
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
|
||||
import { groupBy } from "../../../../../common/util/group-by";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
|
||||
@@ -154,8 +155,9 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
device_database: html`<a
|
||||
rel="noreferrer noopener"
|
||||
href=${
|
||||
this._nodeMetadata?.device_database_url ||
|
||||
"https://devices.zwave-js.io"
|
||||
sanitizeHttpUrl(
|
||||
this._nodeMetadata?.device_database_url
|
||||
) || "https://devices.zwave-js.io"
|
||||
}
|
||||
target="_blank"
|
||||
>${this.hass.localize(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
|
||||
|
||||
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
|
||||
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
|
||||
|
||||
// Build description with learn more link if available
|
||||
const descriptionWithLink = preview_feature.learn_more_url
|
||||
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
|
||||
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
<div class="card-actions">
|
||||
<div>
|
||||
${
|
||||
preview_feature.feedback_url
|
||||
feedbackUrl
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.feedback_url}
|
||||
href=${feedbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
preview_feature.report_issue_url
|
||||
reportIssueUrl
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.report_issue_url}
|
||||
href=${reportIssueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
|
||||
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
|
||||
import { formatSystemLogTime } from "./util";
|
||||
|
||||
/** Compares the host, so a URL that merely contains ours does not pass. */
|
||||
const isOfficialDocumentationUrl = (url: string): boolean => {
|
||||
try {
|
||||
return new URL(url).hostname === "www.home-assistant.io";
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("dialog-system-log-detail")
|
||||
class DialogSystemLogDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
this._manifest &&
|
||||
(this._manifest.is_built_in ||
|
||||
// Custom components with our official docs should not link to our docs
|
||||
!this._manifest.documentation.includes("://www.home-assistant.io"));
|
||||
(!!this._manifest.documentation &&
|
||||
!isOfficialDocumentationUrl(this._manifest.documentation)));
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const title = this.hass.localize("ui.panel.config.logs.details", {
|
||||
level: html`<span class=${item.level}
|
||||
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
${
|
||||
!this._manifest ||
|
||||
// Can happen with custom integrations
|
||||
!showDocumentation
|
||||
!showDocumentation ||
|
||||
!documentationLink
|
||||
? ""
|
||||
: html`
|
||||
(<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
@@ -24,7 +25,10 @@ import type {
|
||||
HttpConfig,
|
||||
HttpConfigWithMeta,
|
||||
} from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
@@ -167,6 +171,10 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
@state() private _activeConfigType?: ActiveConfigType;
|
||||
|
||||
// The built-in default config as reported by core; used to show the default
|
||||
// port in the helper text instead of a hard-coded value.
|
||||
@state() private _default?: HttpConfigWithMeta;
|
||||
|
||||
// A pending config that was reverted/failed and kept only for display.
|
||||
@state() private _revertedPending?: HttpConfigWithMeta;
|
||||
|
||||
@@ -201,6 +209,8 @@ class HaConfigHttpForm extends LitElement {
|
||||
const portChanged =
|
||||
!!this._stable && this._config?.server_port !== this._stable.server_port;
|
||||
|
||||
const hasListenAddresses = !!this._config?.server_host?.some(Boolean);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -255,6 +265,17 @@ class HaConfigHttpForm extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
hasListenAddresses
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.server_host_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -309,12 +330,16 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
const { stable, pending, active_config_type } = await fetchHttpConfig(
|
||||
this.hass
|
||||
);
|
||||
const {
|
||||
stable,
|
||||
pending,
|
||||
active_config_type,
|
||||
default: defaultConfig,
|
||||
} = await fetchHttpConfig(this.hass);
|
||||
this._stable = stable;
|
||||
this._config = { ...stable };
|
||||
this._activeConfigType = active_config_type;
|
||||
this._default = defaultConfig;
|
||||
// An active trial pending (no error) is handled by the global
|
||||
// confirm/revert dialog. A pending carrying an error was reverted or
|
||||
// failed to apply and is kept only so we can surface it here.
|
||||
@@ -351,6 +376,12 @@ class HaConfigHttpForm extends LitElement {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
return "";
|
||||
}
|
||||
if (schema.name === "server_port") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.network.http.helpers.server_port",
|
||||
{ port: this._default?.server_port ?? 8123 }
|
||||
);
|
||||
}
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.helpers.${schema.name}` as any
|
||||
@@ -365,6 +396,63 @@ class HaConfigHttpForm extends LitElement {
|
||||
this._showNoChanges = false;
|
||||
}
|
||||
|
||||
// Build a link to the new address for an address-changing restart, so the
|
||||
// user (still on the old address) can jump to it once Home Assistant is back.
|
||||
// Best-effort: skip Home Assistant Cloud remote UI (Nabu Casa), and skip when
|
||||
// the current page is not on the old port — that usually means a reverse
|
||||
// proxy, where swapping the port would point at the wrong place. Even when
|
||||
// shown, the new address may not be reachable (e.g. a firewall).
|
||||
private _newAddressUrl(): string | undefined {
|
||||
if (!this._stable || !this._config) {
|
||||
return undefined;
|
||||
}
|
||||
const loc = mainWindow.location;
|
||||
if (loc.hostname.endsWith("nabu.casa")) {
|
||||
return undefined;
|
||||
}
|
||||
const oldHttps = !!this._stable.ssl_certificate;
|
||||
const newHttps = !!this._config.ssl_certificate;
|
||||
const oldPort = this._stable.server_port ?? (oldHttps ? 443 : 80);
|
||||
const newPort = this._config.server_port ?? (newHttps ? 443 : 80);
|
||||
// The reachable address only changes when the scheme or the port changes.
|
||||
if (oldHttps === newHttps && oldPort === newPort) {
|
||||
return undefined;
|
||||
}
|
||||
const currentPort = loc.port
|
||||
? Number(loc.port)
|
||||
: loc.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
if (currentPort !== oldPort) {
|
||||
return undefined;
|
||||
}
|
||||
const url = new URL(loc.origin);
|
||||
url.protocol = newHttps ? "https:" : "http:";
|
||||
url.port = String(newPort);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private _showNewAddress(url: string): void {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_address.title"
|
||||
),
|
||||
text: html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_address.text"
|
||||
)}
|
||||
</p>
|
||||
<a href=${url} rel="noreferrer noopener">${url}</a>
|
||||
<p class="dialog-note">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_address.note"
|
||||
)}
|
||||
</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._config || !this._stable) {
|
||||
return;
|
||||
@@ -393,24 +481,44 @@ class HaConfigHttpForm extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the new address before the restart drops the connection.
|
||||
const newAddressUrl = this._newAddressUrl();
|
||||
|
||||
this._saving = true;
|
||||
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;
|
||||
} else if (newAddressUrl) {
|
||||
// restart === true: a restart is in flight. The reply usually races
|
||||
// with the connection drop; if we do reach this branch, offer the new
|
||||
// address so the user can follow along.
|
||||
this._showNewAddress(newAddressUrl);
|
||||
}
|
||||
// restart === true: a restart is in flight. The reply usually races with
|
||||
// the connection drop; if we do reach this branch, the disconnected
|
||||
// overlay will appear in moments. Leave the form as is.
|
||||
} catch (err: any) {
|
||||
// The restart kills the WS connection before the ack — that's expected.
|
||||
if (
|
||||
err?.error?.code === ERR_CONNECTION_LOST ||
|
||||
err === ERR_CONNECTION_LOST
|
||||
) {
|
||||
if (newAddressUrl) {
|
||||
this._showNewAddress(newAddressUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._handleSaveError(err);
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { HaInputCopy } from "../../../components/input/ha-input-copy";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { fetchCloudStatus } from "../../../data/cloud";
|
||||
import { saveCoreConfig } from "../../../data/core";
|
||||
import type { HttpConfigWithMeta } from "../../../data/http";
|
||||
import { fetchHttpConfig } from "../../../data/http";
|
||||
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -34,6 +36,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _internal_url = "";
|
||||
|
||||
// Stable HTTP config from core; used to build a realistic internal URL
|
||||
// placeholder (scheme from SSL, port from the configured/default port).
|
||||
@state() private _httpConfig?: HttpConfigWithMeta;
|
||||
|
||||
@state() private _httpDefault?: HttpConfigWithMeta;
|
||||
|
||||
@state() private _cloudStatus?: CloudStatus | null;
|
||||
|
||||
@state() private _showCustomExternalUrl = false;
|
||||
@@ -97,6 +105,28 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build realistic URL placeholders: use https when an SSL certificate is
|
||||
// configured, and only show the port when it differs from the scheme
|
||||
// default (80 for http, 443 for https).
|
||||
const useHttps = this._httpConfig?.ssl_certificate ? true : httpUseHttps;
|
||||
const placeholderPort =
|
||||
this._httpConfig?.server_port ?? this._httpDefault?.server_port;
|
||||
const placeholderParts = {
|
||||
protocol: useHttps ? "https" : "http",
|
||||
port:
|
||||
placeholderPort && placeholderPort !== (useHttps ? 443 : 80)
|
||||
? `:${placeholderPort}`
|
||||
: "",
|
||||
};
|
||||
const internalUrlPlaceholder = this.hass.localize(
|
||||
"ui.panel.config.url.internal_url_placeholder",
|
||||
placeholderParts
|
||||
);
|
||||
const externalUrlPlaceholder = this.hass.localize(
|
||||
"ui.panel.config.url.external_url_placeholder",
|
||||
placeholderParts
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -157,7 +187,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
data-name="external_url"
|
||||
type="url"
|
||||
.maskedToggle=${!(this._showCustomExternalUrl && canEdit)}
|
||||
placeholder="https://example.duckdns.org:8123"
|
||||
.placeholder=${externalUrlPlaceholder}
|
||||
.value=${externalUrl}
|
||||
.maskedValue=${
|
||||
this._showCustomExternalUrl && canEdit
|
||||
@@ -260,9 +290,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
data-name="internal_url"
|
||||
.maskedToggle=${!(this._showCustomInternalUrl && canEdit)}
|
||||
type="url"
|
||||
placeholder=${this.hass.localize(
|
||||
"ui.panel.config.url.internal_url_placeholder"
|
||||
)}
|
||||
.placeholder=${internalUrlPlaceholder}
|
||||
.value=${internalUrl}
|
||||
.maskedValue=${
|
||||
this._showCustomInternalUrl && canEdit
|
||||
@@ -325,6 +353,14 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
|
||||
this._cloudStatus = null;
|
||||
}
|
||||
this._fetchUrls();
|
||||
// Best-effort: the placeholder still works without it, just without a port.
|
||||
fetchHttpConfig(this.hass).then(
|
||||
({ stable, default: defaultConfig }) => {
|
||||
this._httpConfig = stable;
|
||||
this._httpDefault = defaultConfig;
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleCloud(ev: Event) {
|
||||
|
||||
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-button";
|
||||
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const learnMoreUrlIsHomeAssistant =
|
||||
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
|
||||
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
this._issue.learn_more_url
|
||||
);
|
||||
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
|
||||
|
||||
const dialogTitle =
|
||||
this.hass.localize(
|
||||
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
|
||||
}
|
||||
</ha-button>
|
||||
${
|
||||
this._issue.learn_more_url
|
||||
learnMoreUrl
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
appearance="filled"
|
||||
rel="noopener noreferrer"
|
||||
href=${
|
||||
learnMoreUrlIsHomeAssistant
|
||||
? this._issue.learn_more_url.replace(
|
||||
"homeassistant://",
|
||||
"/"
|
||||
)
|
||||
: this._issue.learn_more_url
|
||||
}
|
||||
href=${learnMoreUrl}
|
||||
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
|
||||
@click=${
|
||||
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
|
||||
|
||||
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { formatDateTime } from "../../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
|
||||
import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
|
||||
if (info.type === "pending") {
|
||||
value = html` <ha-spinner size="small"></ha-spinner> `;
|
||||
} else if (info.type === "failed") {
|
||||
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
|
||||
value = html`
|
||||
<span class="error">${info.error}</span>${
|
||||
!info.more_info
|
||||
!moreInfoUrl
|
||||
? ""
|
||||
: html`
|
||||
–
|
||||
<a
|
||||
href=${info.more_info}
|
||||
href=${moreInfoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
|
||||
`);
|
||||
}
|
||||
if (domain !== "homeassistant") {
|
||||
// No target, so an in-app path is also a valid destination here
|
||||
const manageUrl =
|
||||
sanitizeHttpUrl(domainInfo.manage_url) ??
|
||||
sanitizeNavigationPath(domainInfo.manage_url);
|
||||
sections.push(html`
|
||||
<div class="card-header">
|
||||
<h3>${domainToName(this.hass.localize, domain)}</h3>
|
||||
${
|
||||
!domainInfo.manage_url
|
||||
!manageUrl
|
||||
? ""
|
||||
: html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
class="manage"
|
||||
href=${domainInfo.manage_url}
|
||||
href=${manageUrl}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.info.system_health.manage"
|
||||
|
||||
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
|
||||
<ha-md-list>
|
||||
${this._setups?.map((setup) => {
|
||||
const manifest = this._manifests && this._manifests[setup.domain];
|
||||
const docLink = manifest
|
||||
? manifest.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
|
||||
: manifest.documentation
|
||||
: "";
|
||||
const docLink =
|
||||
(manifest
|
||||
? manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${manifest.domain}`
|
||||
)
|
||||
: manifest.documentation
|
||||
: "") || "";
|
||||
|
||||
const setupSeconds = setup.seconds?.toFixed(2);
|
||||
return html`
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
isTemplate,
|
||||
} from "../../../../common/string/has-template";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
|
||||
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
|
||||
import { extractSearchParam } from "../../../../common/url/search-params";
|
||||
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
|
||||
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<a
|
||||
href=${resolved.url}
|
||||
href=${ifDefined(
|
||||
sanitizeHttpUrl(resolved.url) ??
|
||||
sanitizeNavigationPath(resolved.url)
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
><ha-button>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
mdiRestore,
|
||||
mdiTrashCanOutline,
|
||||
mdiViewSplitHorizontal,
|
||||
mdiViewSplitVertical,
|
||||
} from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-code-editor";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
|
||||
import "../../../../components/ha-label";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-split-panel";
|
||||
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tip";
|
||||
import type { RenderTemplateResult } from "../../../../data/ws-templates";
|
||||
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
|
||||
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
|
||||
{ key: "docs_functions", path: "/template-functions/" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
|
||||
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
|
||||
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
|
||||
const DEFAULT_SPLIT_POSITION = 50;
|
||||
|
||||
type SplitOrientation = "horizontal" | "vertical";
|
||||
|
||||
@customElement("tools-template")
|
||||
class HaPanelDevTemplate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
|
||||
|
||||
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@state() private _descriptionExpanded = false;
|
||||
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
|
||||
|
||||
@query("ha-tip") private _editorTip?: HTMLElement;
|
||||
@state() private _splitOrientation: SplitOrientation = "horizontal";
|
||||
|
||||
private _template = "";
|
||||
|
||||
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
|
||||
// its late-arriving results discarded.
|
||||
private _subscribeRequestId = 0;
|
||||
|
||||
private _tipResizeObserver?: ResizeObserver;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._template && !this._unsubRenderTemplate) {
|
||||
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeTemplate();
|
||||
this._tipResizeObserver?.disconnect();
|
||||
this._tipResizeObserver = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (localStorage && localStorage["panel-dev-template-template"]) {
|
||||
this._template = localStorage["panel-dev-template-template"];
|
||||
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
|
||||
this._template = localStorage[STORAGE_KEY_TEMPLATE];
|
||||
} else {
|
||||
this._template = DEMO_TEMPLATE;
|
||||
}
|
||||
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
|
||||
if (storedPosition) {
|
||||
const parsed = parseFloat(storedPosition);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
||||
this._splitPosition = parsed;
|
||||
}
|
||||
}
|
||||
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
|
||||
this._splitOrientation = "vertical";
|
||||
}
|
||||
this._subscribeTemplate();
|
||||
this._observeTipHeight();
|
||||
this._inited = true;
|
||||
}
|
||||
|
||||
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
|
||||
: "dict"
|
||||
: type;
|
||||
|
||||
const editorCard = this._renderEditorCard();
|
||||
const resultCard = this._renderResultCard(type, resultType);
|
||||
|
||||
// On narrow viewports side-by-side is too cramped, so force the (still
|
||||
// resizable) stacked layout and hide the orientation toggle.
|
||||
const orientation = this.narrow ? "vertical" : this._splitOrientation;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="about">
|
||||
<ha-expansion-panel
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.about"
|
||||
)}
|
||||
outlined
|
||||
.expanded=${this._descriptionExpanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div class="description">
|
||||
<p>
|
||||
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
layout: !this.narrow,
|
||||
horizontal: !this.narrow,
|
||||
})}"
|
||||
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
|
||||
>
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._restoreDemo}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.reset"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._clear}>
|
||||
${this.hass.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._templateResult
|
||||
? html`<pre
|
||||
class="rendered ${classMap({
|
||||
[resultType]: resultType,
|
||||
})}"
|
||||
>
|
||||
<ha-split-panel
|
||||
class="panes ${orientation === "vertical" ? "vertical" : ""}"
|
||||
.position=${this._splitPosition}
|
||||
.orientation=${orientation}
|
||||
snap="50%"
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
>
|
||||
<div slot="start" class="pane">${editorCard}</div>
|
||||
<div slot="end" class="pane">${resultCard}</div>
|
||||
${this.narrow ? nothing : this._renderOrientationToggle()}
|
||||
</ha-split-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOrientationToggle() {
|
||||
const label = this.hass.localize(
|
||||
this._splitOrientation === "vertical"
|
||||
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
|
||||
: "ui.panel.config.tools.tabs.templates.layout_stacked"
|
||||
);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
slot="divider"
|
||||
class="divider-toggle"
|
||||
.title=${label}
|
||||
aria-label=${label}
|
||||
@mousedown=${stopPropagation}
|
||||
@touchstart=${stopPropagation}
|
||||
@click=${this._toggleOrientation}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this._splitOrientation === "vertical"
|
||||
? mdiViewSplitVertical
|
||||
: mdiViewSplitHorizontal
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
|
||||
// copy, search and fullscreen buttons; the trailing divider separates them.
|
||||
private _editorToolbarItems = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"]
|
||||
): (HaIconButtonToolbarItem | string)[] => [
|
||||
{
|
||||
id: "restore-demo",
|
||||
label: localize("ui.panel.config.tools.tabs.templates.reset"),
|
||||
path: mdiRestore,
|
||||
action: () => this._restoreDemo(),
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: localize("ui.common.clear"),
|
||||
path: mdiTrashCanOutline,
|
||||
action: () => this._clear(),
|
||||
},
|
||||
"divider",
|
||||
]
|
||||
);
|
||||
|
||||
private _renderEditorCard() {
|
||||
return html`
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
${
|
||||
this.narrow
|
||||
? nothing
|
||||
: html`
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
`
|
||||
}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderResultCard(type: string, resultType: string) {
|
||||
const showEmptyState =
|
||||
!this._error && !this._rendering && !this._template?.trim();
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showEmptyState
|
||||
? html`<div class="empty">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_placeholder"
|
||||
)}
|
||||
</div>`
|
||||
: this._templateResult
|
||||
? html`
|
||||
<ha-label dense>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</ha-label>
|
||||
<pre class="rendered">
|
||||
${
|
||||
type === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result
|
||||
}</pre>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</p>
|
||||
${
|
||||
this._templateResult.listeners.time
|
||||
? html`
|
||||
@@ -316,109 +410,179 @@ ${
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _observeTipHeight() {
|
||||
if (!this._editorTip || this._tipResizeObserver) {
|
||||
return;
|
||||
}
|
||||
this._tipResizeObserver = new ResizeObserver((entries) => {
|
||||
const height =
|
||||
entries[0]?.borderBoxSize?.[0]?.blockSize ??
|
||||
entries[0]?.contentRect.height;
|
||||
if (height) {
|
||||
this.style.setProperty("--tip-height", `${height}px`);
|
||||
}
|
||||
});
|
||||
this._tipResizeObserver.observe(this._editorTip);
|
||||
private _splitRepositioned(ev: Event) {
|
||||
this._splitPosition = (ev.target as HaSplitPanel).position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
private _expandedChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
|
||||
) {
|
||||
this._descriptionExpanded = ev.detail.expanded;
|
||||
private _toggleOrientation() {
|
||||
this._splitOrientation =
|
||||
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
|
||||
if (this._inited) {
|
||||
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
private _storeSplitPosition = debounce(
|
||||
() => {
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-4);
|
||||
.about {
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.content:has(ha-expansion-panel) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content.horizontal {
|
||||
--panel-header-height: calc(
|
||||
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
|
||||
var(--ha-font-size-m) + 1px + 2px
|
||||
);
|
||||
--description-pane-height: calc(
|
||||
var(--ha-space-4) + 48px +
|
||||
(
|
||||
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
|
||||
var(--ha-space-1) * 2
|
||||
) *
|
||||
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
|
||||
);
|
||||
--card-header-height: calc(
|
||||
var(--ha-space-3) + var(--ha-space-4) +
|
||||
var(--ha-line-height-expanded) *
|
||||
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
|
||||
);
|
||||
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
|
||||
--tip-height-minimal: calc(
|
||||
var(--mdc-icon-size, 24px) + var(--ha-space-4)
|
||||
);
|
||||
--edit-pane-height: calc(
|
||||
100vh - var(--panel-header-height) - var(
|
||||
--description-pane-height
|
||||
) - var(--ha-space-4) *
|
||||
2
|
||||
);
|
||||
--code-mirror-max-height: calc(
|
||||
var(--edit-pane-height) - var(--card-header-height) +
|
||||
var(--ha-space-2) - var(--card-actions-height) - var(
|
||||
--tip-height,
|
||||
var(--tip-height-minimal)
|
||||
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
|
||||
2
|
||||
);
|
||||
.about a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-4);
|
||||
--ha-split-panel-min: 20%;
|
||||
--ha-split-panel-max: 80%;
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* On wide viewports we slot our own handle (the orientation toggle)
|
||||
into the divider, so hide the default grip. On narrow there is no
|
||||
toggle, so keep the default grip as the resize affordance. */
|
||||
:host(:not([narrow])) .panes {
|
||||
--ha-split-panel-grip-display: none;
|
||||
}
|
||||
|
||||
/* Orientation toggle that lives on the divider. Clicking it toggles
|
||||
orientation; resizing is done by dragging the divider elsewhere. */
|
||||
.divider-toggle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 16px;
|
||||
transition:
|
||||
color var(--ha-animation-duration-fast) ease-out,
|
||||
border-color var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.divider-toggle:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.divider-toggle:focus-visible {
|
||||
outline: none;
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.pane[slot="start"] {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane[slot="end"] {
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="start"] {
|
||||
padding-inline-end: 0;
|
||||
padding-block-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="end"] {
|
||||
padding-inline-start: 0;
|
||||
padding-block-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane ha-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-pane ha-code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
--code-mirror-height: 100%;
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.edit-pane {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.edit-pane a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content.horizontal > * {
|
||||
width: 50%;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.render-spinner {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
@@ -428,10 +592,24 @@ ${
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.render-pane ha-label {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
padding: var(--ha-space-4);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.rendered {
|
||||
font-family: var(--ha-font-family-code);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
@@ -439,6 +617,7 @@ ${
|
||||
clear: both;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
@@ -447,7 +626,7 @@ ${
|
||||
|
||||
p,
|
||||
ul {
|
||||
margin-block-end: 0;
|
||||
margin-block: 0;
|
||||
}
|
||||
.description > p {
|
||||
margin-block-start: 0;
|
||||
@@ -468,26 +647,6 @@ ${
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane .card-content {
|
||||
overflow: auto;
|
||||
max-height: calc(
|
||||
var(--code-mirror-max-height) +
|
||||
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
|
||||
);
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane {
|
||||
overflow: hidden;
|
||||
padding-bottom: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
}
|
||||
|
||||
.all_listeners {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
@@ -507,19 +666,6 @@ ${
|
||||
background-color: var(--secondary-background-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
.content ha-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
}
|
||||
.card-actions > ha-button:last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -615,7 +761,7 @@ ${
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage["panel-dev-template-template"] = this._template;
|
||||
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
|
||||
}
|
||||
|
||||
private async _restoreDemo() {
|
||||
@@ -631,7 +777,7 @@ ${
|
||||
}
|
||||
this._template = DEMO_TEMPLATE;
|
||||
this._subscribeTemplate();
|
||||
delete localStorage["panel-dev-template-template"];
|
||||
delete localStorage[STORAGE_KEY_TEMPLATE];
|
||||
}
|
||||
|
||||
private async _clear() {
|
||||
@@ -647,12 +793,8 @@ ${
|
||||
}
|
||||
this._unsubscribeTemplate();
|
||||
this._template = "";
|
||||
// Reset to empty result. Setting to 'undefined' results in a different visual
|
||||
// behaviour compared to manually emptying the template input box.
|
||||
this._templateResult = {
|
||||
result: "",
|
||||
listeners: { all: false, entities: [], domains: [], time: false },
|
||||
};
|
||||
// An empty template shows the placeholder empty state.
|
||||
this._templateResult = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
|
||||
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
|
||||
|
||||
render() {
|
||||
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
|
||||
// the frontend's own origin
|
||||
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
|
||||
return html`
|
||||
<hass-error-screen
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
error="Unable to load iframes with this URL."
|
||||
rootnav
|
||||
></hass-error-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
if (
|
||||
location.protocol === "https:" &&
|
||||
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
|
||||
|
||||
@@ -321,6 +321,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
"entity_id",
|
||||
"state",
|
||||
"event_type",
|
||||
"name",
|
||||
"message",
|
||||
"source",
|
||||
"context_id",
|
||||
"context_user_id",
|
||||
"context_event_type",
|
||||
@@ -339,6 +342,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
d.entity_id || "",
|
||||
csvSafeString(d.state),
|
||||
csvSafeString(d.attributes?.event_type),
|
||||
csvSafeString(d.name),
|
||||
csvSafeString(d.message),
|
||||
csvSafeString(d.source),
|
||||
d.context_id || "",
|
||||
d.context_user_id || "",
|
||||
csvSafeString(d.context_event_type),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
"--tile-color": color,
|
||||
};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
${
|
||||
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${Boolean(this._hasCardAction)}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info
|
||||
slot="info"
|
||||
class=${ifDefined(
|
||||
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
|
||||
)}
|
||||
.primary=${primary}
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
@@ -818,6 +825,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
ha-tile-info.twoline::part(primary) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
|
||||
throw new Error("URL required");
|
||||
}
|
||||
|
||||
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
|
||||
// the frontend's own origin
|
||||
if (sanitizeUrl(config.url) === "about:blank") {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: ShortcutCardConfig;
|
||||
|
||||
private _navInfo = new NavigationPathInfoController(this);
|
||||
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const style = color ? { "--tile-color": color } : {};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
<ha-tile-container
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
|
||||
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: TileCardConfig;
|
||||
|
||||
@state() private _featureContext: LovelaceCardFeatureContext = {};
|
||||
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const hasImage = Boolean(imageUrl);
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
@@ -133,7 +134,7 @@ export const handleAction = async (
|
||||
break;
|
||||
case "url": {
|
||||
if (actionConfig.url_path) {
|
||||
window.open(actionConfig.url_path);
|
||||
window.open(sanitizeUrl(actionConfig.url_path));
|
||||
} else {
|
||||
showToast(node, {
|
||||
message: hass.localize("ui.panel.lovelace.cards.actions.no_url"),
|
||||
|
||||
@@ -54,7 +54,7 @@ export class HuiNumericInputCardFeatureEditor
|
||||
}
|
||||
|
||||
const data: NumericInputCardFeatureConfig = {
|
||||
style: "buttons",
|
||||
style: "slider",
|
||||
...this._config,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import {
|
||||
getCustomBadgeEntry,
|
||||
getCustomCardEntry,
|
||||
@@ -38,7 +39,9 @@ export const getCardDocumentationURL = (
|
||||
type: string
|
||||
): string | undefined => {
|
||||
if (isCustomType(type)) {
|
||||
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
|
||||
return sanitizeHttpUrl(
|
||||
getCustomCardEntry(stripCustomPrefix(type))?.documentationURL
|
||||
);
|
||||
}
|
||||
|
||||
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_CARD_URLS[type] || type}`;
|
||||
@@ -49,7 +52,9 @@ export const getBadgeDocumentationURL = (
|
||||
type: string
|
||||
): string | undefined => {
|
||||
if (isCustomType(type)) {
|
||||
return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL;
|
||||
return sanitizeHttpUrl(
|
||||
getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL
|
||||
);
|
||||
}
|
||||
|
||||
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_BADGE_URLS[type] || "badges"}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user