mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-10 23:14:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32bc0696e | ||
|
|
6327304f4d | ||
|
|
11c79e09e9 | ||
|
|
5060b37381 | ||
|
|
63e3a04f15 | ||
|
|
818de1da8e | ||
|
|
70897feab1 | ||
|
|
f827c09e72 | ||
|
|
a4bb97b71c | ||
|
|
509561aabe | ||
|
|
0446eb5a62 | ||
|
|
e815c44a42 | ||
|
|
4551a87a7a | ||
|
|
23e376c7b4 | ||
|
|
a67b691a45 | ||
|
|
fc0f86e46d | ||
|
|
ef6438a77d | ||
|
|
7b4981e850 | ||
|
|
a46e90de67 | ||
|
|
3dfadb7fdf | ||
|
|
6f3ef16d20 | ||
|
|
17d08d26f6 | ||
|
|
aa06e8ce25 | ||
|
|
122877cdf5 | ||
|
|
a103eac243 | ||
|
|
a9a8a9aad9 | ||
|
|
35a054c1ba | ||
|
|
960e99ed66 | ||
|
|
232375befb | ||
|
|
91ee29a2e6 | ||
|
|
a6dc5d0008 | ||
|
|
7a66de362e | ||
|
|
36500e5b19 | ||
|
|
df88136147 | ||
|
|
2f1d738c2c | ||
|
|
bfeb26b47d | ||
|
|
8de5bfee89 | ||
|
|
1efc7f8a95 | ||
|
|
9e0d38fba8 | ||
|
|
2932f8f528 | ||
|
|
6a0902d18c | ||
|
|
4424f98eb6 | ||
|
|
ea13594fb0 | ||
|
|
ce726a8a3f | ||
|
|
d67479da6d | ||
|
|
275e30879c | ||
|
|
7b53005865 | ||
|
|
94950fcacd | ||
|
|
4214fe46c6 | ||
|
|
d6325eef7d | ||
|
|
12e722f122 | ||
|
|
83eb3ca13a | ||
|
|
0532b1f5f6 | ||
|
|
970afb588d | ||
|
|
23137ce6d2 | ||
|
|
6b022d63e8 | ||
|
|
d3c6e132b7 | ||
|
|
c08b261d4b | ||
|
|
0721b22036 |
No files matched your search
@@ -284,6 +284,7 @@ module.exports.config = {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
|
||||
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
|
||||
...module.exports.mapWorkerEntry(),
|
||||
},
|
||||
outputPath: outputPath(paths.demo_output_root, latestBuild),
|
||||
@@ -291,6 +292,8 @@ module.exports.config = {
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
// The embed sets the global to load static files from its own server.
|
||||
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
|
||||
@@ -232,7 +232,11 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
const DEMO_PAGE_ENTRIES = {
|
||||
"index.html": ["main"],
|
||||
"components.html": ["embed"],
|
||||
"embed.js": ["embed"],
|
||||
};
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-dev",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -214,16 +214,18 @@ const createRspackConfig = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
// MapLibre's worker loads ESM plugins through `import(url)`, and the
|
||||
// page side builds a blob import from `new URL(url, import.meta.url)`
|
||||
// for a cross-origin worker. Neither path is taken here: the RTL
|
||||
// plugin is UMD and the worker is served from the page's origin.
|
||||
// Rspack's stubs for these fully dynamic requests are exactly what
|
||||
// should remain - rejecting, and free of syntax the legacy floor
|
||||
// cannot parse - so the warnings about them are noise.
|
||||
// MapLibre's worker loads ESM plugins through `import(url)`, which
|
||||
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
|
||||
// fully dynamic request is exactly what should remain - rejecting,
|
||||
// and free of syntax the legacy floor cannot parse - so the warning
|
||||
// about it is noise. The page side builds a blob import from
|
||||
// `new URL(url, import.meta.url)` for a worker on another origin,
|
||||
// as in the demo embed. That is a URL, not an asset to bundle, and
|
||||
// the worker URL is absolute, so leave it as it is.
|
||||
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
|
||||
parser: {
|
||||
exprContextCritical: false,
|
||||
url: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,11 +244,22 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// The demo embed reads its own URL at runtime to find its files.
|
||||
// Keep import.meta.url instead of replacing it with the source path.
|
||||
// Development builds wrap modules in eval(), which rejects import.meta.
|
||||
latestBuild &&
|
||||
isProdBuild && {
|
||||
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
|
||||
parser: { importMeta: false },
|
||||
},
|
||||
].filter(Boolean),
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
@@ -8,13 +8,13 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/manifest.json
|
||||
Cache-Control: public, max-age: 3600, s-maxage=3600
|
||||
Cache-Control: public, max-age=3600, s-maxage=3600
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
+12
-5
@@ -1,18 +1,25 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/api/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/embed.js
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/static/*
|
||||
Access-Control-Allow-Origin: *
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
export const embedHassContext = createContext<HomeAssistant>("embedHass");
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "../../../src/common/decorators/consume";
|
||||
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { embedHassContext } from "./context";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { readScriptObject } from "./script-data";
|
||||
|
||||
const pageProvider = (): HTMLElement =>
|
||||
document.querySelector("ha-embed-provider") ??
|
||||
document.body.appendChild(document.createElement("ha-embed-provider"));
|
||||
|
||||
// Looks past shadow roots, so an element inside another component still
|
||||
// finds the provider around that component.
|
||||
const insideProvider = (element: Element): boolean => {
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current) {
|
||||
if (current.closest("ha-embed-provider")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const root = current.getRootNode();
|
||||
current = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render dashboard content. The element
|
||||
* uses the provider with the ID in `provider`, else the closest provider
|
||||
* around it, else the first provider on the page. If the page has no
|
||||
* provider, the element adds an empty one.
|
||||
*/
|
||||
export abstract class EmbedElement<
|
||||
Config extends ScriptObject,
|
||||
> extends LitElement {
|
||||
/**
|
||||
* The configuration of the content, the same as in a dashboard. Also read
|
||||
* from a JSON or YAML script child, see `readScriptObject`.
|
||||
*/
|
||||
@property({ type: Object }) public config?: Config;
|
||||
|
||||
/** The ID of the `ha-embed-provider` to use. */
|
||||
@property() public provider?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: embedHassContext, subscribe: true })
|
||||
protected _hass?: HomeAssistant;
|
||||
|
||||
// A provider that is not an ancestor, which gets the context requests of the
|
||||
// element and its content. Context requests only reach ancestors by
|
||||
// themselves.
|
||||
private _linkedProvider?: HTMLElement;
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener("context-request", (ev) => {
|
||||
if (!this._linkedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
this._linkedProvider.dispatchEvent(
|
||||
new ContextEvent(
|
||||
ev.context,
|
||||
ev.contextTarget,
|
||||
ev.callback,
|
||||
ev.subscribe
|
||||
)
|
||||
);
|
||||
});
|
||||
// Cards fire their tap, hold and double tap actions for the app to handle
|
||||
this.addEventListener("hass-action", (ev) => {
|
||||
if (this._hass) {
|
||||
// The element that fired the action, so that fire-dom-event reaches
|
||||
// listeners inside custom cards, as in action-mixin
|
||||
const [origin] = ev.composedPath();
|
||||
const node = origin instanceof HTMLElement ? origin : this;
|
||||
|
||||
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
|
||||
}
|
||||
});
|
||||
// The page's address belongs to the page, so only a hash on it, such as
|
||||
// an anchor, navigates there. The browser does that itself, as the app's
|
||||
// navigate() doesn't scroll to the anchor.
|
||||
this.addEventListener("hass-navigate", (ev) => {
|
||||
const { path, replace } = ev.detail;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
if (!path.startsWith("#")) {
|
||||
this.navigateTo(path, replace);
|
||||
} else if (replace) {
|
||||
window.location.replace(path);
|
||||
} else {
|
||||
window.location.hash = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
// Before super.connectedCallback(), which sends the context requests.
|
||||
this._linkedProvider = this.provider
|
||||
? (document.getElementById(this.provider) ?? undefined)
|
||||
: insideProvider(this)
|
||||
? undefined
|
||||
: pageProvider();
|
||||
super.connectedCallback();
|
||||
|
||||
// Only once, so that moving the element keeps a config set since
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const data = readScriptObject(this);
|
||||
|
||||
if (data !== undefined) {
|
||||
this.setScriptData(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A navigate action of the content, other than to a hash on the page. It
|
||||
* leaves the page where it is, like actions that open a dialog. `replace`
|
||||
* is the action's `navigation_replace`.
|
||||
*/
|
||||
protected navigateTo(_path: string, _replace?: boolean) {
|
||||
// Nothing to navigate to, outside a dashboard
|
||||
}
|
||||
|
||||
/** Whether the data of a script child is a configuration of the content. */
|
||||
protected abstract isConfig(data: ScriptObject): data is Config;
|
||||
|
||||
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (this.isConfig(data)) {
|
||||
this.config = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** The content, or `undefined` while there is nothing to render. */
|
||||
protected abstract renderContent(
|
||||
hass: HomeAssistant
|
||||
): TemplateResult | undefined;
|
||||
|
||||
// The content in the theme of the provider
|
||||
protected render() {
|
||||
const hass = this._hass;
|
||||
const content = hass && this.renderContent(hass);
|
||||
|
||||
if (!hass || !content) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div
|
||||
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
|
||||
dir=${computeRTLDirection(hass)}
|
||||
>
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "./public-path";
|
||||
import "./workers";
|
||||
// The provider must be defined first. Custom elements upgrade in the order
|
||||
// they are defined, and cards need a provider to request their context from.
|
||||
import "./ha-embed-provider";
|
||||
import "./ha-embed-card";
|
||||
import "./ha-embed-section";
|
||||
import "./ha-embed-view";
|
||||
import "./ha-embed-dashboard";
|
||||
// For custom cards, which can't load them themselves. The built-in cards only
|
||||
// load ha-list-item-button with the map card. The full app loads ha-select
|
||||
// with the more info dialog.
|
||||
import "../../../src/components/item/ha-list-item-button";
|
||||
import "../../../src/components/ha-select";
|
||||
import { fontStyles } from "../../../src/resources/roboto";
|
||||
|
||||
// Font faces only work in the document, not in the shadow root of the card.
|
||||
// Defining Roboto does not change the page unless it already uses Roboto.
|
||||
document.head.append(
|
||||
Object.assign(document.createElement("style"), {
|
||||
textContent: fontStyles.cssText,
|
||||
})
|
||||
);
|
||||
|
||||
// Custom cards use the helpers to create built-in cards. The dashboard panel
|
||||
// sets the same global in the full app.
|
||||
(window as any).loadCardHelpers = () =>
|
||||
import("../../../src/panels/lovelace/custom-card-helpers");
|
||||
@@ -0,0 +1,99 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
|
||||
import "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
|
||||
*
|
||||
* The card takes the columns and rows it would take in a section, so cards
|
||||
* in an element with `display: grid` and 12 columns lay out like a section.
|
||||
* It also sets `--column-size` and `--row-size` on itself.
|
||||
*/
|
||||
@customElement("ha-embed-card")
|
||||
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
private _card = createRef<HuiCard>();
|
||||
|
||||
// A card names its type
|
||||
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
|
||||
return "type" in data && typeof data.type === "string";
|
||||
}
|
||||
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<hui-card
|
||||
${ref(this._card)}
|
||||
.hass=${hass}
|
||||
.config=${this.config}
|
||||
.layout=${"grid"}
|
||||
@card-updated=${this._updateSize}
|
||||
@card-visibility-changed=${this._updateSize}
|
||||
></hui-card>`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
// The card element only exists once hui-card has updated
|
||||
this._card.value?.updateComplete.then(() => this._updateSize());
|
||||
}
|
||||
|
||||
// The size the card gets in a section, set on the host so that the page can
|
||||
// lay out cards in a grid of 12 columns, like a section
|
||||
private _updateSize = () => {
|
||||
const card = this._card.value;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const { rows, columns } = computeCardGridSize(card.getGridOptions());
|
||||
this.hidden = card.hidden;
|
||||
this.toggleAttribute("full-width", columns === "full");
|
||||
this.toggleAttribute("fit-rows", typeof rows === "number");
|
||||
this.style.setProperty(
|
||||
"--column-size",
|
||||
typeof columns === "number" ? String(columns) : null
|
||||
);
|
||||
this.style.setProperty(
|
||||
"--row-size",
|
||||
typeof rows === "number" ? String(rows) : null
|
||||
);
|
||||
};
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
css`
|
||||
/* The sizes of a card in a section, see hui-grid-section */
|
||||
:host {
|
||||
--row-gap: var(--ha-section-grid-row-gap, 8px);
|
||||
--row-height: var(--ha-section-grid-row-height, 56px);
|
||||
display: block;
|
||||
grid-row: span var(--row-size, 1);
|
||||
grid-column: span min(var(--column-size, 12), 12);
|
||||
}
|
||||
:host([full-width]) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
:host([fit-rows]) .theme {
|
||||
height: calc(
|
||||
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
|
||||
--row-gap
|
||||
)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-card": HaEmbedCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import "../../../src/panels/lovelace/hui-root";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
|
||||
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
|
||||
* view in `view`, so switching tabs and following links to its views don't
|
||||
* change the page's URL. The dashboard scrolls inside the element, under its
|
||||
* toolbar.
|
||||
*/
|
||||
@customElement("ha-embed-dashboard")
|
||||
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
|
||||
private _scroller = createRef<HTMLDivElement>();
|
||||
|
||||
// The route of hui-root, here without a page URL around it. A new
|
||||
// configuration gives a new route, as hui-root only looks up the view of
|
||||
// the path again when its route changes, and the views may have moved.
|
||||
private _route = memoizeOne(
|
||||
(view: string | undefined, _config: LovelaceConfig) => ({
|
||||
prefix: "",
|
||||
path: `/${view ?? ""}`,
|
||||
})
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
|
||||
return isDashboardConfig(data);
|
||||
}
|
||||
|
||||
// The view shown, and the views before it, which the back button of a
|
||||
// subview without a `back_path` goes back through, as the browser's back
|
||||
// would in the app
|
||||
private _shownView?: string;
|
||||
|
||||
private _history: (string | undefined)[] = [];
|
||||
|
||||
// Going back, or a navigate action with `navigation_replace`, which
|
||||
// replaces the view shown rather than adding to the views before it
|
||||
private _skipHistory = false;
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this.view === this._shownView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._shownView !== undefined) {
|
||||
if (!this._skipHistory) {
|
||||
this._history.push(this._shownView);
|
||||
}
|
||||
|
||||
this._scroller.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
this._skipHistory = false;
|
||||
this._shownView = this.view;
|
||||
}
|
||||
|
||||
protected navigateTo(path: string, replace?: boolean) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view === undefined || this.isSameView(view, this.view)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._skipHistory = Boolean(replace);
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
// The embedded attribute is in the template, so that hui-root has it when
|
||||
// it connects and skips the listeners on the page
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this._lovelace?.config.views.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<div class="scroller" ${ref(this._scroller)}>
|
||||
<hui-root
|
||||
embedded
|
||||
no-edit
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.route=${this._route(this.view, this._lovelace.config)}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
@view-navigate=${this._viewNavigate}
|
||||
></hui-root>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// A tab, or the back button of a subview, which goes to its `back_path`
|
||||
// when that is one of the dashboard's views, else to the view before it
|
||||
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
|
||||
const view = this.viewOfPath(ev.detail.path);
|
||||
|
||||
if (!ev.detail.back) {
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const target = view === undefined ? this._previousView() : view;
|
||||
|
||||
// Following a `back_path` goes back too, so the view it goes to leaves
|
||||
// the views before
|
||||
if (
|
||||
view !== undefined &&
|
||||
this.isSameView(this._history[this._history.length - 1], view)
|
||||
) {
|
||||
this._history.pop();
|
||||
}
|
||||
|
||||
if (!this.isSameView(target, this.view)) {
|
||||
this._skipHistory = true;
|
||||
this.view = target;
|
||||
}
|
||||
}
|
||||
|
||||
// The last view before that isn't the one shown, else the first view
|
||||
private _previousView(): string | undefined {
|
||||
while (this._history.length) {
|
||||
const previous = this._history.pop();
|
||||
|
||||
if (!this.isSameView(previous, this.view)) {
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
/* Contained, so that the toolbar and background of hui-root, which are
|
||||
fixed to the window in the app, are fixed to the element */
|
||||
:host {
|
||||
height: 600px;
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
.scroller {
|
||||
height: 100%;
|
||||
}
|
||||
.scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-dashboard": HaEmbedDashboard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { ContextProvider, provide } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../../src/common/dom/media_query";
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import { reducedMotionContext } from "../../../src/data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
|
||||
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoArea } from "../stubs/area_registry";
|
||||
import { toAreaRegistryEntry } from "../stubs/area_registry";
|
||||
import { mockAuth } from "../stubs/auth";
|
||||
import {
|
||||
FAMILY_EVENTS,
|
||||
mockCalendarEvents,
|
||||
WORK_EVENTS,
|
||||
} from "../stubs/calendar";
|
||||
import type { DemoDevice } from "../stubs/device_registry";
|
||||
import { toDeviceRegistryEntry } from "../stubs/device_registry";
|
||||
import { extEntry, newEntry } from "../stubs/entity_registry";
|
||||
import { mockHistory } from "../stubs/history";
|
||||
import { mockIcons } from "../stubs/icons";
|
||||
import { mockLogbook } from "../stubs/logbook";
|
||||
import { mockMediaPlayer } from "../stubs/media_player";
|
||||
import { mockRecorder } from "../stubs/recorder";
|
||||
import { mockSensor } from "../stubs/sensor";
|
||||
import { mockExtractFromTarget } from "../stubs/target";
|
||||
import { mockTemplate } from "../stubs/template";
|
||||
import { mockTodo } from "../stubs/todo";
|
||||
import { mockWeather } from "../stubs/weather";
|
||||
import { embedHassContext } from "./context";
|
||||
import { readScriptData } from "./script-data";
|
||||
|
||||
/** The data of a provider, as set by its script child. */
|
||||
interface EmbedProviderData {
|
||||
entities?: EntityInput[];
|
||||
areas?: DemoArea[];
|
||||
devices?: DemoDevice[];
|
||||
calendars?: Record<string, CalendarEventApiData[]>;
|
||||
energy?: Partial<EnergyPreferences>;
|
||||
}
|
||||
|
||||
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
|
||||
|
||||
// "living_room" becomes "Living room"
|
||||
const areaName = (areaId: string) => {
|
||||
const name = areaId.replaceAll("_", " ");
|
||||
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a mocked Home Assistant backend for all embed elements inside it.
|
||||
* Elements in the same provider share their entities.
|
||||
*/
|
||||
@customElement("ha-embed-provider")
|
||||
export class HaEmbedProvider extends LitElement {
|
||||
@provide({ context: embedHassContext })
|
||||
@property({ attribute: false })
|
||||
public hass!: MockHomeAssistant;
|
||||
|
||||
/**
|
||||
* All entities of the backend. Each change replaces all entities.
|
||||
* Also read from a JSON or YAML script child, see `readScriptData`. The
|
||||
* script holds either the list of entities or an object with `entities`,
|
||||
* `areas`, `devices`, `calendars` and `energy`.
|
||||
*/
|
||||
@property({ type: Array }) public entities: EntityInput[] = [];
|
||||
|
||||
/**
|
||||
* All areas. Without them, an area for each `area_id` of the entities and
|
||||
* devices.
|
||||
*/
|
||||
@property({ attribute: false }) public areas?: DemoArea[];
|
||||
|
||||
/** All devices, which entities reference with their `device_id`. */
|
||||
@property({ attribute: false }) public devices?: DemoDevice[];
|
||||
|
||||
/**
|
||||
* Events of calendar entities by entity ID. Calendars without events here
|
||||
* get sample events around today.
|
||||
*/
|
||||
@property({ attribute: false }) public calendars?: Record<
|
||||
string,
|
||||
CalendarEventApiData[]
|
||||
>;
|
||||
|
||||
/** Energy preferences. Without them, energy is not set up. */
|
||||
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
|
||||
|
||||
/** Light or dark mode of the cards. Without it, the browser setting. */
|
||||
@property({ attribute: "color-scheme" }) public colorScheme?:
|
||||
"light" | "dark";
|
||||
|
||||
private _reducedMotion = new ContextProvider(this, {
|
||||
context: reducedMotionContext,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
private _unsubMediaQueries: (() => void)[] = [];
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
private _applyColorScheme() {
|
||||
const dark = this.colorScheme
|
||||
? this.colorScheme === "dark"
|
||||
: this._prefersDark;
|
||||
if (this.hass.themes.darkMode !== dark) {
|
||||
this.hass.updateHass({
|
||||
themes: { ...this.hass.themes, darkMode: dark },
|
||||
selectedTheme: { ...this.hass.selectedTheme!, dark },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Set up in the constructor so that the contexts exist before the
|
||||
// child cards connect and request them.
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null);
|
||||
hass.updateTranslations("lovelace");
|
||||
mockIcons(hass);
|
||||
mockAuth(hass);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTemplate(hass);
|
||||
mockSensor(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockTodo(hass);
|
||||
mockWeather(hass);
|
||||
mockLogbook(hass);
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "logbook"],
|
||||
},
|
||||
});
|
||||
mockCalendarEvents(hass, (entityId, currentHass) => {
|
||||
if (!(entityId in currentHass.states)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const calendarIds = Object.keys(currentHass.states).filter((id) =>
|
||||
id.startsWith("calendar.")
|
||||
);
|
||||
|
||||
return (
|
||||
this.calendars?.[entityId] ??
|
||||
SAMPLE_CALENDAR_EVENTS[
|
||||
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
|
||||
]
|
||||
);
|
||||
});
|
||||
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.areas)
|
||||
);
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
const registryEntries = (currentHass: MockHomeAssistant) =>
|
||||
Object.values(currentHass.entities).map(
|
||||
(entity): EntityRegistryEntry => ({
|
||||
...newEntry({
|
||||
entity_id: entity.entity_id,
|
||||
platform: entity.platform ?? "demo",
|
||||
unique_id: entity.entity_id,
|
||||
}),
|
||||
area_id: entity.area_id ?? null,
|
||||
device_id: entity.device_id ?? null,
|
||||
options:
|
||||
this.entities.find((item) => item.entity_id === entity.entity_id)
|
||||
?.options ?? null,
|
||||
})
|
||||
);
|
||||
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
|
||||
registryEntries(currentHass)
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get",
|
||||
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => {
|
||||
const entry = registryEntries(currentHass).find(
|
||||
(item) => item.entity_id === msg.entity_id
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`Entity not found: ${msg.entity_id}`);
|
||||
}
|
||||
return extEntry(entry);
|
||||
}
|
||||
);
|
||||
mockExtractFromTarget(hass);
|
||||
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
|
||||
...EMPTY_PREFERENCES,
|
||||
...this.energy,
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._unsubMediaQueries = [
|
||||
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
|
||||
this._prefersDark = matches;
|
||||
this._applyColorScheme();
|
||||
}),
|
||||
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
|
||||
this._reducedMotion.setValue(matches)
|
||||
),
|
||||
];
|
||||
|
||||
// Only once, so that moving the provider keeps the current entities
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const data = readScriptData(this) as
|
||||
EntityInput[] | EmbedProviderData | undefined;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
this.entities = data;
|
||||
} else if (data) {
|
||||
this.entities = data.entities ?? [];
|
||||
this.areas = data.areas;
|
||||
this.devices = data.devices;
|
||||
this.calendars = data.calendars;
|
||||
this.energy = data.energy;
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubMediaQueries.forEach((unsub) => unsub());
|
||||
this._unsubMediaQueries = [];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("colorScheme")) {
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has("entities") ||
|
||||
changedProps.has("areas") ||
|
||||
changedProps.has("devices")
|
||||
) {
|
||||
const devices = this.devices ?? [];
|
||||
const areas: DemoArea[] =
|
||||
this.areas ??
|
||||
[
|
||||
...new Set(
|
||||
[...this.entities, ...devices].flatMap((item) =>
|
||||
item.area_id === undefined ? [] : [item.area_id]
|
||||
)
|
||||
),
|
||||
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
|
||||
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = toAreaRegistryEntry(area);
|
||||
});
|
||||
const devicesById: Record<string, DeviceRegistryEntry> = {};
|
||||
devices.forEach((device) => {
|
||||
devicesById[device.id] = toDeviceRegistryEntry(device);
|
||||
});
|
||||
// Before the entities, so that their names can include the area and
|
||||
// device
|
||||
this.hass.updateHass({ areas: areasById, devices: devicesById });
|
||||
|
||||
// Entities that services changed keep their state when only the areas
|
||||
// or devices change
|
||||
if (changedProps.has("entities")) {
|
||||
this.hass.addEntities(this.entities, true);
|
||||
} else {
|
||||
this.hass.updateFormatFunctions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
const oldHass = changedProps.get("hass");
|
||||
// The first update holds the initial entities. They are not changes.
|
||||
if (!oldHass) {
|
||||
return;
|
||||
}
|
||||
// Other updates, such as loaded translations, keep the same states object.
|
||||
if (oldHass.states !== this.hass.states) {
|
||||
fireEvent(this, "states-changed", { states: this.hass.states });
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-provider": HaEmbedProvider;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"states-changed": { states: HassEntities };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
|
||||
import "../../../src/panels/lovelace/sections/hui-section";
|
||||
import {
|
||||
renderSectionContainer,
|
||||
sectionContainerStyles,
|
||||
} from "../../../src/panels/lovelace/sections/section-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a dashboard section, or a section strategy, with the backend of a
|
||||
* provider, see `EmbedElement`. The section takes the width of the element.
|
||||
*/
|
||||
@customElement("ha-embed-section")
|
||||
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
|
||||
// A title becomes a heading card, as on a dashboard
|
||||
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
|
||||
|
||||
// A section has cards, sections inside it, a strategy that makes them, a
|
||||
// title, or the type of a custom section
|
||||
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
|
||||
return (
|
||||
"cards" in data ||
|
||||
"sections" in data ||
|
||||
"strategy" in data ||
|
||||
"title" in data ||
|
||||
("type" in data && typeof data.type === "string")
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
// The section that hid the element is gone, and the next one starts
|
||||
// visible without telling it
|
||||
if (changedProps.has("config") && !this.config) {
|
||||
this.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// In the container that draws the background of the section, as in a
|
||||
// sections view
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = this._normalizedConfig(this.config);
|
||||
|
||||
return renderSectionContainer(
|
||||
hass,
|
||||
config,
|
||||
html`<hui-section
|
||||
.hass=${hass}
|
||||
.config=${config}
|
||||
@section-visibility-changed=${this._visibilityChanged}
|
||||
></hui-section>`
|
||||
);
|
||||
}
|
||||
|
||||
private _visibilityChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
|
||||
) {
|
||||
// Sections inside the section fire the same event
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hidden = !ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
sectionContainerStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-section": HaEmbedSection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import "../../../src/panels/lovelace/views/hui-view";
|
||||
import "../../../src/panels/lovelace/views/hui-view-background";
|
||||
import "../../../src/panels/lovelace/views/hui-view-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
EmbedLovelaceElement,
|
||||
findViewIndex,
|
||||
isDashboardConfig,
|
||||
} from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard view, a view strategy, or one view of a dashboard or
|
||||
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
|
||||
* `config` holds the view.
|
||||
*/
|
||||
@customElement("ha-embed-view")
|
||||
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
|
||||
/**
|
||||
* A dashboard configuration or dashboard strategy, to show its `view`
|
||||
* instead of `config`. Also read from the `dashboard` key of a script child,
|
||||
* next to `view`.
|
||||
*/
|
||||
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
|
||||
|
||||
private _viewDashboard = memoizeOne(
|
||||
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
|
||||
}
|
||||
|
||||
// A view strategy and a dashboard strategy both have a `strategy` key, so
|
||||
// a dashboard comes in its own key, which no view has
|
||||
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
|
||||
return !("dashboard" in data);
|
||||
}
|
||||
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (!("dashboard" in data)) {
|
||||
super.setScriptData(data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { dashboard } = data;
|
||||
|
||||
if (
|
||||
typeof dashboard === "object" &&
|
||||
dashboard !== null &&
|
||||
isDashboardConfig(dashboard)
|
||||
) {
|
||||
this.dashboard = dashboard;
|
||||
}
|
||||
|
||||
if (
|
||||
"view" in data &&
|
||||
(typeof data.view === "string" || typeof data.view === "number")
|
||||
) {
|
||||
this.view ??= String(data.view);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing for a dashboard without views, such as an empty strategy result.
|
||||
// The view's theme and background, or the dashboard's background, as in
|
||||
// hui-root. Each view gets its own hui-view, as there, so that a view
|
||||
// strategy still generating for the view before can't replace the cards of
|
||||
// this one.
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
if (!this._lovelace || !views?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const index = findViewIndex(views, this.view);
|
||||
const viewConfig = views[index];
|
||||
|
||||
const background =
|
||||
viewConfig.background || this._lovelace.config.background;
|
||||
|
||||
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
|
||||
<hui-view-background .hass=${hass} .background=${background}>
|
||||
</hui-view-background>
|
||||
${keyed(
|
||||
viewConfig,
|
||||
html`<hui-view
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${index}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
></hui-view>`
|
||||
)}
|
||||
</hui-view-container>`;
|
||||
}
|
||||
|
||||
// A fixed background stays inside the element. With a height set on the
|
||||
// element, a panel view fills it, and other views keep their own height.
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
:host {
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
hui-view-container,
|
||||
hui-view {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* The background sits below the view, above the element's own */
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-view": HaEmbedView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import {
|
||||
checkStrategyShouldRegenerate,
|
||||
generateLovelaceDashboardStrategy,
|
||||
} from "../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
// The width below which the app is narrow, see home-assistant-main
|
||||
const NARROW_WIDTH = 870;
|
||||
|
||||
/** The index of the view with this path or index, if there is one. */
|
||||
const viewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view: string
|
||||
): number | undefined => {
|
||||
const byPath = views.findIndex((item) => item.path === view);
|
||||
|
||||
if (byPath !== -1) {
|
||||
return byPath;
|
||||
}
|
||||
|
||||
const index = Number(view);
|
||||
|
||||
return Number.isInteger(index) && index >= 0 && index < views.length
|
||||
? index
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Whether script data is a dashboard: its views, or a strategy for them. */
|
||||
export const isDashboardConfig = (
|
||||
data: ScriptObject
|
||||
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
|
||||
|
||||
/** The index of the view with this path or index, else the first view. */
|
||||
export const findViewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view?: string
|
||||
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render a dashboard, or one of its
|
||||
* views. It generates dashboard strategies, again when the registries they
|
||||
* depend on change, and holds a dashboard that can't be edited or saved.
|
||||
*/
|
||||
export abstract class EmbedLovelaceElement<
|
||||
Config extends ScriptObject,
|
||||
> extends EmbedElement<Config> {
|
||||
/** The path or index of the view to show. Without it, the first view. */
|
||||
@property() public view?: string;
|
||||
|
||||
@state() protected _lovelace?: Lovelace;
|
||||
|
||||
// Narrow like the app at the same width, measured on the element because
|
||||
// it rarely takes the width of the page
|
||||
protected _narrow = new ResizeController(this, {
|
||||
callback: (entries) =>
|
||||
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
|
||||
});
|
||||
|
||||
// The configuration of the dashboard in `_lovelace`, or being generated
|
||||
private _rawConfig?: LovelaceRawConfig;
|
||||
|
||||
// Counts the generations started, so that only the latest one is shown.
|
||||
// Generating again for changed registries keeps the same configuration.
|
||||
private _generation = 0;
|
||||
|
||||
// The backend at the last update, to tell what a strategy depends on changed
|
||||
private _previousHass?: HomeAssistant;
|
||||
|
||||
/** The dashboard to render. The same object until it changes. */
|
||||
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
|
||||
|
||||
/**
|
||||
* The view of the dashboard a path goes to, relative like `batteries` or
|
||||
* with the dashboard like `/maintenance/batteries`, if it has that view.
|
||||
*/
|
||||
protected viewOfPath(path: string | undefined): string | undefined {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
const view = path
|
||||
? decodeURI(new URL(path, "http://embed/").pathname)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
: undefined;
|
||||
|
||||
return views && view !== undefined && viewIndex(views, view) !== undefined
|
||||
? view
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Whether two paths or indexes go to the same view of the dashboard. */
|
||||
protected isSameView(a?: string, b?: string): boolean {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
return views
|
||||
? findViewIndex(views, a) === findViewIndex(views, b)
|
||||
: a === b;
|
||||
}
|
||||
|
||||
// A path to one of the dashboard's views shows that view. Other paths leave
|
||||
// the dashboard where it is.
|
||||
protected navigateTo(path: string) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
const raw = this.rawConfig;
|
||||
const oldHass = this._previousHass;
|
||||
|
||||
this._previousHass = this._hass;
|
||||
|
||||
// Without a configuration, nothing is shown, and a generation still
|
||||
// running is dropped
|
||||
if (!raw) {
|
||||
if (this._rawConfig) {
|
||||
this._rawConfig = undefined;
|
||||
this._lovelace = undefined;
|
||||
this._generation++;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A strategy is only asked whether to generate again when the backend
|
||||
// changed, as in ha-panel-lovelace, not when the generated dashboard
|
||||
// arrives
|
||||
if (
|
||||
raw !== this._rawConfig ||
|
||||
(oldHass &&
|
||||
oldHass !== this._hass &&
|
||||
isStrategyDashboard(raw) &&
|
||||
this._hass.config.state === "RUNNING" &&
|
||||
checkStrategyShouldRegenerate(
|
||||
"dashboard",
|
||||
raw.strategy,
|
||||
oldHass,
|
||||
this._hass
|
||||
))
|
||||
) {
|
||||
this._rawConfig = raw;
|
||||
this._generate(raw, this._hass, ++this._generation);
|
||||
}
|
||||
}
|
||||
|
||||
private async _generate(
|
||||
raw: LovelaceRawConfig,
|
||||
hass: HomeAssistant,
|
||||
generation: number
|
||||
) {
|
||||
const generated = isStrategyDashboard(raw)
|
||||
? await generateLovelaceDashboardStrategy(raw, hass)
|
||||
: raw;
|
||||
|
||||
const config = normalizeLovelaceConfig(generated);
|
||||
|
||||
// A newer generation has started. A generated dashboard has its views,
|
||||
// rather than a strategy.
|
||||
if (generation !== this._generation || isStrategyDashboard(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lovelace = {
|
||||
config,
|
||||
rawConfig: raw,
|
||||
editMode: false,
|
||||
urlPath: null,
|
||||
mode: "generated",
|
||||
locale: hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
// The background of a dashboard, and no insets for a notch or home bar
|
||||
static styles: CSSResultGroup = [
|
||||
embedElementStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
--app-safe-area-inset-top: 0px;
|
||||
--app-safe-area-inset-bottom: 0px;
|
||||
--app-safe-area-inset-left: 0px;
|
||||
--app-safe-area-inset-right: 0px;
|
||||
}
|
||||
.theme {
|
||||
position: relative;
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare global {
|
||||
var __HA_STATIC_PATH__: string | undefined;
|
||||
}
|
||||
declare let __webpack_public_path__: string;
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
// Load chunks and static files from the server that hosts this script, so
|
||||
// that other sites can embed the cards. Only the modern production build keeps
|
||||
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
|
||||
// builds only run on the demo site, where the default paths are correct.
|
||||
// `new URL(path, import.meta.url)` in one expression is an asset reference for
|
||||
// the bundler, so the script URL goes into a variable first.
|
||||
if (__BUILD__ === "modern" && !__DEV__) {
|
||||
const scriptUrl = import.meta.url;
|
||||
__webpack_public_path__ = new URL(".", scriptUrl).href;
|
||||
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { load } from "js-yaml";
|
||||
|
||||
/**
|
||||
* Parses the data in a `<script type="application/json">` or
|
||||
* `<script type="text/yaml">` child of the element.
|
||||
*/
|
||||
export const readScriptData = (element: HTMLElement): unknown => {
|
||||
const script = element.querySelector<HTMLScriptElement>(
|
||||
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
|
||||
);
|
||||
if (!script) {
|
||||
return undefined;
|
||||
}
|
||||
return script.type === "text/yaml"
|
||||
? load(script.textContent!)
|
||||
: JSON.parse(script.textContent!);
|
||||
};
|
||||
|
||||
/** The data of a script child that holds an object, such as a config. */
|
||||
export type ScriptObject = object;
|
||||
|
||||
/** The object in a script child, see `readScriptData`, if it holds one. */
|
||||
export const readScriptObject = (
|
||||
element: HTMLElement
|
||||
): ScriptObject | undefined => {
|
||||
const data = readScriptData(element);
|
||||
|
||||
return typeof data === "object" && data !== null && !Array.isArray(data)
|
||||
? data
|
||||
: undefined;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { css, unsafeCSS } from "lit";
|
||||
import { hex2rgb } from "../../../src/common/color/convert-color";
|
||||
import { darkColorVariables } from "../../../src/resources/theme/color";
|
||||
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
|
||||
import { themeStyles } from "../../../src/resources/theme/theme";
|
||||
|
||||
// The theme styles set variables on `html`, next to fonts and other page
|
||||
// styles. Only the variables, media queries, and keyframes are copied, and
|
||||
// `html` becomes `selector`. This keeps the theme inside the card, so that it
|
||||
// does not change the page that embeds the card.
|
||||
const scopeThemeStyles = (cssText: string, selector: string): string => {
|
||||
// A style element in a separate document parses the CSS without applying it.
|
||||
const doc = document.implementation.createHTMLDocument("");
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = cssText;
|
||||
doc.head.append(style);
|
||||
|
||||
const convert = (rules: CSSRuleList): string =>
|
||||
Array.from(rules, (rule) => {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
if (rule.selectorText !== "html") {
|
||||
return "";
|
||||
}
|
||||
const variables = Array.from(rule.style)
|
||||
.filter((name) => name.startsWith("--"))
|
||||
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
|
||||
return `${selector}{${variables.join("")}}`;
|
||||
}
|
||||
if (rule instanceof CSSMediaRule) {
|
||||
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
|
||||
}
|
||||
if (rule instanceof CSSKeyframesRule) {
|
||||
return rule.cssText;
|
||||
}
|
||||
return "";
|
||||
}).join("");
|
||||
|
||||
return convert(style.sheet!.cssRules);
|
||||
};
|
||||
|
||||
const darkTheme = {
|
||||
...darkSemanticVariables,
|
||||
...darkColorVariables,
|
||||
};
|
||||
|
||||
// Like processTheme in apply_themes_on_element, each hex color also sets its
|
||||
// `--rgb-` variable, else those keep their light theme values
|
||||
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
|
||||
const variable = `--${name}:${value};`;
|
||||
|
||||
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
|
||||
return [variable];
|
||||
}
|
||||
|
||||
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
|
||||
});
|
||||
|
||||
export const embedThemeStyles = unsafeCSS(
|
||||
scopeThemeStyles(themeStyles, ".theme") +
|
||||
`.theme.dark{${darkVariables.join("")}}`
|
||||
);
|
||||
|
||||
/**
|
||||
* The styles of every embed element: the theme, and the page styles of the
|
||||
* app, for the `.theme` wrapper of `EmbedElement`.
|
||||
*/
|
||||
export const embedElementStyles = [
|
||||
embedThemeStyles,
|
||||
css`
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
/* The text styles the app sets on html and body, so that the content does
|
||||
not inherit the text styles of the page that embeds it. */
|
||||
.theme {
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-body);
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
text-align: start;
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
}
|
||||
/* What setDirectionStyles sets on the app for a right-to-left language,
|
||||
here only for the content */
|
||||
.theme[dir="rtl"] {
|
||||
--direction: rtl;
|
||||
--float-start: right;
|
||||
--float-end: left;
|
||||
--scale-direction: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
// Browsers only start a worker from a script on the page's own origin, and
|
||||
// the workers of the cards, such as the markdown worker, are served next to
|
||||
// this script. A worker from another origin starts from a blob instead, which
|
||||
// loads the real worker script. The demo serves its scripts with CORS.
|
||||
const pageWorker = globalThis.Worker;
|
||||
|
||||
globalThis.Worker = class extends pageWorker {
|
||||
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
|
||||
const url = new URL(scriptUrl, location.href);
|
||||
|
||||
if (url.origin === location.origin) {
|
||||
super(scriptUrl, options);
|
||||
return;
|
||||
}
|
||||
|
||||
const source =
|
||||
options?.type === "module"
|
||||
? `import ${JSON.stringify(url.href)};`
|
||||
: `importScripts(${JSON.stringify(url.href)});`;
|
||||
const blobUrl = URL.createObjectURL(
|
||||
new Blob([source], { type: "text/javascript" })
|
||||
);
|
||||
|
||||
try {
|
||||
super(blobUrl, options);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -36,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockLogbook } from "./stubs/logbook";
|
||||
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
@@ -50,6 +51,7 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import { mockWeather } from "./stubs/weather";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
@@ -75,6 +77,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"get_conditions_for_target",
|
||||
"get_services_for_target",
|
||||
"extract_from_target",
|
||||
"explain_target",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
@@ -190,7 +193,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockSystemLog(hass);
|
||||
mockTemplate(hass);
|
||||
mockEvents(hass);
|
||||
mockLogbook(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockWeather(hass);
|
||||
mockFrontend(hass);
|
||||
mockHardware(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
import "<%= latestEntryJS[0] %>";
|
||||
@@ -12,6 +12,20 @@ export interface DemoArea {
|
||||
|
||||
let areas: AreaRegistryEntry[] = [];
|
||||
|
||||
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
});
|
||||
|
||||
export const mockAreaRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DemoArea[] = []
|
||||
@@ -25,19 +39,7 @@ export const setDemoAreas = (
|
||||
hass: MockHomeAssistant,
|
||||
demoAreas: DemoArea[] = []
|
||||
) => {
|
||||
areas = demoAreas.map((area) => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
areas = demoAreas.map(toAreaRegistryEntry);
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = area;
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { ExtractFromTargetResult } from "../../../src/data/target";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { getLabelIds } from "./label_registry";
|
||||
import {
|
||||
conditionDescriptions,
|
||||
triggerDescriptions,
|
||||
} from "./automation_platforms_data";
|
||||
import { explainTarget } from "./explain_target";
|
||||
import { expandTarget, mockExtractFromTarget } from "./target";
|
||||
|
||||
type Descriptions = TriggerDescriptions | ConditionDescriptions;
|
||||
|
||||
@@ -20,109 +20,6 @@ interface EntityFilter {
|
||||
supported_features?: number[];
|
||||
}
|
||||
|
||||
// Expands a target to its areas, devices and entities, like core does
|
||||
const expandTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const missing = {
|
||||
floors: [] as string[],
|
||||
areas: [] as string[],
|
||||
devices: [] as string[],
|
||||
labels: [] as string[],
|
||||
};
|
||||
const knownLabelIds = getLabelIds();
|
||||
const floorIds = ensureArray(target.floor_id ?? []);
|
||||
const labelIds = ensureArray(target.label_id ?? []);
|
||||
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
|
||||
// Like core, unknown labels are reported, but still expanded
|
||||
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
|
||||
const hasLabel = (labels?: string[]) =>
|
||||
labelIds.some((labelId) => labels?.includes(labelId));
|
||||
|
||||
// Unknown areas and devices are reported, but still referenced
|
||||
const areaIds = new Set(ensureArray(target.area_id ?? []));
|
||||
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
|
||||
for (const area of Object.values(hass.areas)) {
|
||||
if (
|
||||
(area.floor_id && floorIds.includes(area.floor_id)) ||
|
||||
hasLabel(area.labels)
|
||||
) {
|
||||
areaIds.add(area.area_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A targeted or labeled device includes its child devices
|
||||
const deviceIds = new Set<string>();
|
||||
const addDevice = (deviceId: string) => {
|
||||
deviceIds.add(deviceId);
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (device.parent_device_id === deviceId) {
|
||||
deviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const deviceId of ensureArray(target.device_id ?? [])) {
|
||||
if (!(deviceId in hass.devices)) {
|
||||
missing.devices.push(deviceId);
|
||||
}
|
||||
addDevice(deviceId);
|
||||
}
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (hasLabel(device.labels)) {
|
||||
addDevice(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A child device without its own area is in the area of its parent device
|
||||
const deviceArea = (deviceId: string) => {
|
||||
const device = hass.devices[deviceId];
|
||||
return (
|
||||
device?.area_id ??
|
||||
(device?.parent_device_id
|
||||
? hass.devices[device.parent_device_id]?.area_id
|
||||
: undefined)
|
||||
);
|
||||
};
|
||||
const areaDeviceIds = new Set<string>();
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
const areaId = deviceArea(device.id);
|
||||
if (areaId && areaIds.has(areaId)) {
|
||||
areaDeviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = new Set(ensureArray(target.entity_id ?? []));
|
||||
for (const entity of Object.values(hass.entities)) {
|
||||
// The display registry keeps the entities of a previous demo, and like
|
||||
// core, hidden entities are never included indirectly
|
||||
if (!(entity.entity_id in hass.states) || entity.hidden) {
|
||||
continue;
|
||||
}
|
||||
const primary = !primaryEntitiesOnly || !entity.entity_category;
|
||||
if (
|
||||
hasLabel(entity.labels) ||
|
||||
(primary &&
|
||||
((entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.has(entity.area_id)) ||
|
||||
// An entity with its own area is not in the area of its device
|
||||
(!entity.area_id &&
|
||||
entity.device_id &&
|
||||
areaDeviceIds.has(entity.device_id))))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
areas: [...areaIds],
|
||||
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
|
||||
entities: [...entityIds],
|
||||
missing,
|
||||
};
|
||||
};
|
||||
|
||||
const matchesFilter = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string,
|
||||
@@ -228,27 +125,20 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
|
||||
);
|
||||
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
|
||||
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
|
||||
mockExtractFromTarget(hass);
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
"explain_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
) =>
|
||||
explainTarget(
|
||||
msg.target,
|
||||
(target, primaryEntitiesOnly) =>
|
||||
expandTarget(currentHass, target, primaryEntitiesOnly),
|
||||
Object.keys(currentHass.states),
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
hass.mockWS(
|
||||
"get_services_for_target",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// Sample events relative to today, so demos always show something in the
|
||||
// current week and month.
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const day = (offset: number): Date => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + offset);
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
const isoDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
|
||||
const at = (offset: number, hours: number, minutes = 0) => {
|
||||
const date = day(offset);
|
||||
date.setHours(hours, minutes);
|
||||
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const allDay = (offset: number, days = 1) => ({
|
||||
start: isoDate(day(offset)),
|
||||
end: isoDate(day(offset + days)),
|
||||
all_day: true,
|
||||
});
|
||||
|
||||
export const FAMILY_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
|
||||
{
|
||||
summary: "Dentist",
|
||||
start: at(0, 15, 30),
|
||||
end: at(0, 16, 15),
|
||||
location: "Main Street 12",
|
||||
},
|
||||
{ summary: "Grandma's birthday", ...allDay(1) },
|
||||
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
|
||||
{
|
||||
summary: "Dinner with friends",
|
||||
start: at(3, 19),
|
||||
end: at(3, 22),
|
||||
status: "tentative",
|
||||
},
|
||||
{ summary: "Summer cabin", ...allDay(5, 3) },
|
||||
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
|
||||
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
|
||||
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
|
||||
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
|
||||
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
|
||||
];
|
||||
|
||||
export const WORK_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
|
||||
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
|
||||
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
|
||||
{ summary: "Conference", ...allDay(8, 2) },
|
||||
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
|
||||
];
|
||||
|
||||
const toTime = (value: CalendarEventApiData["start"]) =>
|
||||
new Date(
|
||||
typeof value === "string"
|
||||
? value
|
||||
: "dateTime" in value
|
||||
? value.dateTime
|
||||
: value.date
|
||||
).getTime();
|
||||
|
||||
const inRange = (
|
||||
events: CalendarEventApiData[],
|
||||
start: string | null,
|
||||
end: string | null
|
||||
): CalendarEventApiData[] => {
|
||||
const startTime = start ? new Date(start).getTime() : -Infinity;
|
||||
const endTime = end ? new Date(end).getTime() : Infinity;
|
||||
|
||||
return events.filter(
|
||||
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers event requests of calendar entities with the events of
|
||||
* `getEvents`. A calendar without events gets `null`, like a calendar whose
|
||||
* integration fails to return them.
|
||||
*/
|
||||
export const mockCalendarEvents = (
|
||||
hass: MockHomeAssistant,
|
||||
getEvents: (
|
||||
entityId: string,
|
||||
currentHass: MockHomeAssistant
|
||||
) => CalendarEventApiData[] | null | undefined
|
||||
) => {
|
||||
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
|
||||
const events = getEvents(msg.entity_id, currentHass);
|
||||
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
|
||||
|
||||
return () => undefined;
|
||||
});
|
||||
// Home Assistant before 2026.5 has no event subscription, so cards that
|
||||
// support it ask the REST API instead.
|
||||
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
|
||||
const url = new URL(path, location.origin);
|
||||
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
|
||||
const events = getEvents(entityId, currentHass);
|
||||
|
||||
if (!events) {
|
||||
return Promise.reject(new Error("Calendar not found"));
|
||||
}
|
||||
|
||||
return inRange(
|
||||
events,
|
||||
url.searchParams.get("start"),
|
||||
url.searchParams.get("end")
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,60 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
/** The fields of a device that the demo devices leave at their default. */
|
||||
export const baseDevice: Omit<
|
||||
DeviceRegistryEntry,
|
||||
| "id"
|
||||
| "name"
|
||||
| "manufacturer"
|
||||
| "model"
|
||||
| "config_entries"
|
||||
| "primary_config_entry"
|
||||
| "entry_type"
|
||||
> = {
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
identifiers: [],
|
||||
model_id: null,
|
||||
labels: [],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export interface DemoDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
area_id?: string;
|
||||
via_device_id?: string;
|
||||
entry_type?: "service";
|
||||
}
|
||||
|
||||
export const toDeviceRegistryEntry = (
|
||||
device: DemoDevice
|
||||
): DeviceRegistryEntry => ({
|
||||
...baseDevice,
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
manufacturer: device.manufacturer ?? null,
|
||||
model: device.model ?? null,
|
||||
area_id: device.area_id ?? null,
|
||||
via_device_id: device.via_device_id ?? null,
|
||||
entry_type: device.entry_type ?? null,
|
||||
config_entries: [],
|
||||
primary_config_entry: null,
|
||||
});
|
||||
|
||||
export const mockDeviceRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DeviceRegistryEntry[] = []
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
import { baseDevice } from "./device_registry";
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
|
||||
+20
-20
@@ -11,7 +11,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.solar_production",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Solar",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -20,7 +20,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.battery_input",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Battery Input",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -29,7 +29,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.battery_output",
|
||||
state: "3",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Battery Output",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -38,7 +38,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_1",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Grid consumption low tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -47,7 +47,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_2",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Grid consumption high tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -56,7 +56,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_1",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Returned to grid low tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -65,7 +65,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_2",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Returned to grid high tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -74,7 +74,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_1_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -82,7 +82,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_2_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -90,7 +90,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_1_compensation",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -98,7 +98,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_2_compensation",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_gas_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -146,7 +146,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_gas",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Gas",
|
||||
unit_of_measurement: "m³",
|
||||
},
|
||||
@@ -155,7 +155,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_water",
|
||||
state: "4000",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Water",
|
||||
unit_of_measurement: "L",
|
||||
},
|
||||
@@ -164,7 +164,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_car",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Electric car",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -173,7 +173,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_ac",
|
||||
state: "3",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Air conditioning",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -182,7 +182,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_washing_machine",
|
||||
state: "6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Washing machine",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -191,7 +191,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_dryer",
|
||||
state: "5.5",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Dryer",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -200,7 +200,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_heat_pump",
|
||||
state: "6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Heat pump",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -209,7 +209,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_boiler",
|
||||
state: "7",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Boiler",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let entries: EntityRegistryEntry[] = [];
|
||||
|
||||
const newEntry = (
|
||||
export const newEntry = (
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
): EntityRegistryEntry => {
|
||||
const now = Date.now() / 1000;
|
||||
@@ -43,7 +43,9 @@ const displayFields = (entry: EntityRegistryEntry) => ({
|
||||
hidden: entry.hidden_by !== null,
|
||||
});
|
||||
|
||||
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
|
||||
export const extEntry = (
|
||||
entry: EntityRegistryEntry
|
||||
): ExtEntityRegistryEntry => ({
|
||||
...entry,
|
||||
capabilities: {},
|
||||
aliases: [],
|
||||
@@ -56,6 +58,16 @@ export const mockEntityRegistry = (
|
||||
entries = [...data];
|
||||
// A new array, so subscribers see the registry changed after an update
|
||||
hass.mockWS("config/entity_registry/list", () => [...entries]);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get",
|
||||
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
|
||||
const entry = entries.find((e) => e.entity_id === msg.entity_id);
|
||||
if (!entry) {
|
||||
throw new Error(`Entity not found: ${msg.entity_id}`);
|
||||
}
|
||||
return extEntry(entry);
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import type {
|
||||
TargetExplanation,
|
||||
HassServiceTargetWithExclude,
|
||||
TargetItem,
|
||||
TargetType,
|
||||
} from "../../../src/data/target";
|
||||
|
||||
const TYPES: TargetType[] = ["entity", "device", "area", "floor", "label"];
|
||||
|
||||
const items = (target?: HassServiceTarget): TargetItem[] =>
|
||||
TYPES.flatMap((type) => {
|
||||
const ids = target?.[`${type}_id`];
|
||||
return ids ? ensureArray(ids).map((id) => ({ type, id: String(id) })) : [];
|
||||
});
|
||||
|
||||
/** What one target item resolves to, and the areas and devices it reaches. */
|
||||
export interface ResolvedTargetItem {
|
||||
areas: string[];
|
||||
devices: string[];
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like core's explain_target: resolves each item of the target and of its
|
||||
* `exclude` on its own with `resolve`, and tells what each exclude takes out
|
||||
* of each included item. Like core, `entity_id: all` is every entity in
|
||||
* `entityIds`, and excludes resolve every entity, not only the primary ones.
|
||||
*/
|
||||
export const explainTarget = (
|
||||
target: HassServiceTargetWithExclude,
|
||||
resolve: (
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly: boolean
|
||||
) => ResolvedTargetItem,
|
||||
entityIds: string[],
|
||||
primaryEntitiesOnly = true
|
||||
): TargetExplanation => {
|
||||
const single = ({ type, id }: TargetItem): HassServiceTarget => ({
|
||||
[`${type}_id`]: [id],
|
||||
});
|
||||
const includes = items(target).map((item) => {
|
||||
if (item.type === "entity" && item.id === "all") {
|
||||
return {
|
||||
...item,
|
||||
entities: entityIds,
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
};
|
||||
}
|
||||
const { areas, devices, entities } = resolve(
|
||||
single(item),
|
||||
primaryEntitiesOnly
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
entities,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
};
|
||||
});
|
||||
return {
|
||||
includes,
|
||||
excludes: items(target.exclude).map((item) => {
|
||||
const excluded = new Set(resolve(single(item), false).entities);
|
||||
return {
|
||||
...item,
|
||||
removed: includes
|
||||
.map(({ type, id, entities }) => ({
|
||||
type,
|
||||
id,
|
||||
entities: entities.filter((entityId) => excluded.has(entityId)),
|
||||
}))
|
||||
.filter((include) => include.entities.length > 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LogbookStreamMessage } from "../../../src/data/logbook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
/**
|
||||
* Answers logbook subscriptions with an empty logbook. No entries come from
|
||||
* an automation or script trace.
|
||||
*/
|
||||
export const mockLogbook = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
|
||||
onChange?.({ events: [] } satisfies LogbookStreamMessage);
|
||||
|
||||
return () => undefined;
|
||||
});
|
||||
hass.mockWS("trace/contexts", () => ({}));
|
||||
};
|
||||
@@ -149,6 +149,7 @@ const repositories: RepositoryBase[] = [
|
||||
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
|
||||
...repository,
|
||||
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
|
||||
beta: false,
|
||||
default_branch: "main",
|
||||
issues: 12,
|
||||
releases: [repository.available_version],
|
||||
|
||||
@@ -372,7 +372,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
statistics[id] =
|
||||
entityState && "last_reset" in entityState.attributes
|
||||
entityState &&
|
||||
(entityState.attributes.state_class === "total" ||
|
||||
entityState.attributes.state_class === "total_increasing")
|
||||
? generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import type { ExtractFromTargetResult } from "../../../src/data/target";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { getLabelIds } from "./label_registry";
|
||||
|
||||
// Expands a target to its areas, devices and entities, like core does
|
||||
export const expandTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const missing = {
|
||||
floors: [] as string[],
|
||||
areas: [] as string[],
|
||||
devices: [] as string[],
|
||||
labels: [] as string[],
|
||||
};
|
||||
const knownLabelIds = getLabelIds();
|
||||
const floorIds = ensureArray(target.floor_id ?? []);
|
||||
const labelIds = ensureArray(target.label_id ?? []);
|
||||
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
|
||||
// Like core, unknown labels are reported, but still expanded
|
||||
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
|
||||
const hasLabel = (labels?: string[]) =>
|
||||
labelIds.some((labelId) => labels?.includes(labelId));
|
||||
|
||||
// Unknown areas and devices are reported, but still referenced
|
||||
const areaIds = new Set(ensureArray(target.area_id ?? []));
|
||||
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
|
||||
for (const area of Object.values(hass.areas)) {
|
||||
if (
|
||||
(area.floor_id && floorIds.includes(area.floor_id)) ||
|
||||
hasLabel(area.labels)
|
||||
) {
|
||||
areaIds.add(area.area_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A targeted or labeled device includes its child devices
|
||||
const deviceIds = new Set<string>();
|
||||
const addDevice = (deviceId: string) => {
|
||||
deviceIds.add(deviceId);
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (device.parent_device_id === deviceId) {
|
||||
deviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const deviceId of ensureArray(target.device_id ?? [])) {
|
||||
if (!(deviceId in hass.devices)) {
|
||||
missing.devices.push(deviceId);
|
||||
}
|
||||
addDevice(deviceId);
|
||||
}
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (hasLabel(device.labels)) {
|
||||
addDevice(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A child device without its own area is in the area of its parent device
|
||||
const deviceArea = (deviceId: string) => {
|
||||
const device = hass.devices[deviceId];
|
||||
return (
|
||||
device?.area_id ??
|
||||
(device?.parent_device_id
|
||||
? hass.devices[device.parent_device_id]?.area_id
|
||||
: undefined)
|
||||
);
|
||||
};
|
||||
const areaDeviceIds = new Set<string>();
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
const areaId = deviceArea(device.id);
|
||||
if (areaId && areaIds.has(areaId)) {
|
||||
areaDeviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = new Set(ensureArray(target.entity_id ?? []));
|
||||
for (const entity of Object.values(hass.entities)) {
|
||||
// The display registry keeps the entities of a previous demo, and like
|
||||
// core, hidden entities are never included indirectly
|
||||
if (!(entity.entity_id in hass.states) || entity.hidden) {
|
||||
continue;
|
||||
}
|
||||
const primary = !primaryEntitiesOnly || !entity.entity_category;
|
||||
if (
|
||||
hasLabel(entity.labels) ||
|
||||
(primary &&
|
||||
((entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.has(entity.area_id)) ||
|
||||
// An entity with its own area is not in the area of its device
|
||||
(!entity.area_id &&
|
||||
entity.device_id &&
|
||||
areaDeviceIds.has(entity.device_id))))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
areas: [...areaIds],
|
||||
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
|
||||
entities: [...entityIds],
|
||||
missing,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
ForecastAttribute,
|
||||
ModernForecastType,
|
||||
} from "../../../src/data/weather";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const HOUR = 3600000;
|
||||
|
||||
const CONDITIONS = [
|
||||
"partlycloudy",
|
||||
"rainy",
|
||||
"cloudy",
|
||||
"sunny",
|
||||
"pouring",
|
||||
"partlycloudy",
|
||||
"sunny",
|
||||
];
|
||||
|
||||
// Daily changes around the current temperature, so the forecast looks real.
|
||||
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
|
||||
|
||||
const PRECIPITATION = new Map([
|
||||
["rainy", 2.4],
|
||||
["pouring", 9.1],
|
||||
]);
|
||||
|
||||
const forecastEntry = (
|
||||
datetime: Date,
|
||||
temperature: number,
|
||||
condition: string
|
||||
): ForecastAttribute => ({
|
||||
datetime: datetime.toISOString(),
|
||||
condition,
|
||||
temperature: Math.round(temperature * 10) / 10,
|
||||
precipitation: PRECIPITATION.get(condition) ?? 0,
|
||||
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
|
||||
});
|
||||
|
||||
const generateForecast = (
|
||||
forecastType: ModernForecastType,
|
||||
temperature: number,
|
||||
condition: string
|
||||
): ForecastAttribute[] => {
|
||||
const conditions = [condition, ...CONDITIONS.slice(1)];
|
||||
const now = new Date();
|
||||
|
||||
if (forecastType === "hourly") {
|
||||
now.setMinutes(0, 0, 0);
|
||||
|
||||
return Array.from({ length: 24 }, (_, hour) => {
|
||||
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
|
||||
|
||||
// Warmest mid-afternoon and coldest before dawn
|
||||
const change =
|
||||
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
|
||||
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
|
||||
|
||||
return forecastEntry(
|
||||
datetime,
|
||||
temperature + change,
|
||||
conditions[Math.floor(hour / 6)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
now.setHours(12, 0, 0, 0);
|
||||
|
||||
if (forecastType === "twice_daily") {
|
||||
return conditions.flatMap((dayCondition, day) => {
|
||||
const datetime = new Date(now.getTime() + day * 24 * HOUR);
|
||||
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
|
||||
|
||||
return [
|
||||
{
|
||||
...forecastEntry(datetime, dayTemperature, dayCondition),
|
||||
is_daytime: true,
|
||||
},
|
||||
{
|
||||
...forecastEntry(
|
||||
new Date(datetime.getTime() + 12 * HOUR),
|
||||
dayTemperature - 6,
|
||||
dayCondition
|
||||
),
|
||||
is_daytime: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return conditions.map((dayCondition, day) => {
|
||||
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
|
||||
|
||||
return {
|
||||
...forecastEntry(
|
||||
new Date(now.getTime() + day * 24 * HOUR),
|
||||
dayTemperature,
|
||||
dayCondition
|
||||
),
|
||||
templow: Math.round((dayTemperature - 6) * 10) / 10,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers forecast subscriptions of weather entities. An entity with a
|
||||
* `forecast` attribute gets that forecast. Other entities get a forecast made
|
||||
* up from their condition and temperature.
|
||||
*/
|
||||
export const mockWeather = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"weather/subscribe_forecast",
|
||||
(
|
||||
msg: { entity_id: string; forecast_type: ModernForecastType },
|
||||
currentHass,
|
||||
onChange
|
||||
) => {
|
||||
const stateObj = currentHass.states[msg.entity_id];
|
||||
onChange?.({
|
||||
type: msg.forecast_type,
|
||||
forecast: stateObj
|
||||
? (stateObj.attributes.forecast ??
|
||||
generateForecast(
|
||||
msg.forecast_type,
|
||||
Number(stateObj.attributes.temperature ?? 15),
|
||||
stateObj.state
|
||||
))
|
||||
: null,
|
||||
});
|
||||
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,68 +1,14 @@
|
||||
import {
|
||||
FAMILY_EVENTS,
|
||||
mockCalendarEvents,
|
||||
WORK_EVENTS,
|
||||
} from "../../../demo/src/stubs/calendar";
|
||||
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import { CalendarEntityFeature } from "../../../src/data/calendar";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// Sample calendars with events relative to today, so demos always show
|
||||
// something in the current week and month.
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const day = (offset: number): Date => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + offset);
|
||||
return date;
|
||||
};
|
||||
|
||||
const isoDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
|
||||
const at = (offset: number, hours: number, minutes = 0) => {
|
||||
const date = day(offset);
|
||||
date.setHours(hours, minutes);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const allDay = (offset: number, days = 1) => ({
|
||||
start: isoDate(day(offset)),
|
||||
end: isoDate(day(offset + days)),
|
||||
all_day: true,
|
||||
});
|
||||
|
||||
const FAMILY_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
|
||||
{
|
||||
summary: "Dentist",
|
||||
start: at(0, 15, 30),
|
||||
end: at(0, 16, 15),
|
||||
location: "Main Street 12",
|
||||
},
|
||||
{ summary: "Grandma's birthday", ...allDay(1) },
|
||||
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
|
||||
{
|
||||
summary: "Dinner with friends",
|
||||
start: at(3, 19),
|
||||
end: at(3, 22),
|
||||
status: "tentative",
|
||||
},
|
||||
{ summary: "Summer cabin", ...allDay(5, 3) },
|
||||
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
|
||||
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
|
||||
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
|
||||
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
|
||||
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
|
||||
];
|
||||
|
||||
const WORK_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
|
||||
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
|
||||
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
|
||||
{ summary: "Conference", ...allDay(8, 2) },
|
||||
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
|
||||
];
|
||||
|
||||
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
|
||||
"calendar.family": FAMILY_EVENTS,
|
||||
"calendar.work": WORK_EVENTS,
|
||||
@@ -79,8 +25,8 @@ export const CALENDAR_ENTITIES = [
|
||||
friendly_name: "Family",
|
||||
message: "School run",
|
||||
all_day: false,
|
||||
start_time: at(0, 8),
|
||||
end_time: at(0, 8, 30),
|
||||
start_time: FAMILY_EVENTS[0].start,
|
||||
end_time: FAMILY_EVENTS[0].end,
|
||||
location: "",
|
||||
description: "",
|
||||
supported_features:
|
||||
@@ -96,8 +42,8 @@ export const CALENDAR_ENTITIES = [
|
||||
friendly_name: "Work",
|
||||
message: "Stand-up",
|
||||
all_day: false,
|
||||
start_time: at(0, 9),
|
||||
end_time: at(0, 9, 15),
|
||||
start_time: WORK_EVENTS[0].start,
|
||||
end_time: WORK_EVENTS[0].end,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -145,20 +91,7 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
|
||||
registryEntry("calendar.empty"),
|
||||
registryEntry("calendar.holidays"),
|
||||
]);
|
||||
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
|
||||
const start = new Date(msg.start).getTime();
|
||||
const end = new Date(msg.end).getTime();
|
||||
const events = EVENTS[msg.entity_id];
|
||||
onChange?.({
|
||||
events:
|
||||
events?.filter(
|
||||
(event) =>
|
||||
new Date(event.end as string).getTime() > start &&
|
||||
new Date(event.start as string).getTime() < end
|
||||
) ?? null,
|
||||
});
|
||||
return () => undefined;
|
||||
});
|
||||
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
|
||||
// Changes made in the event dialogs are accepted but not stored
|
||||
hass.mockWS("calendar/event/create", () => null);
|
||||
hass.mockWS("calendar/event/update", () => null);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { explainTarget } from "../../../../demo/src/stubs/explain_target";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { ensureArray } from "../../../../src/common/array/ensure-array";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
@@ -225,6 +226,20 @@ class DemoHaTargetPickerExclusions
|
||||
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
|
||||
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
|
||||
hass.mockWS("extract_from_target", extractFromTarget);
|
||||
hass.mockWS("explain_target", ({ target }: { target: HassServiceTarget }) =>
|
||||
explainTarget(
|
||||
target,
|
||||
(single) => {
|
||||
const resolved = extractFromTarget({ target: single });
|
||||
return {
|
||||
areas: resolved.referenced_areas,
|
||||
devices: resolved.referenced_devices,
|
||||
entities: resolved.referenced_entities,
|
||||
};
|
||||
},
|
||||
Object.keys(hass.states)
|
||||
)
|
||||
);
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20261028.0"
|
||||
version = "20261028.5"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
|
||||
|
||||
const MAX_ROWS = 2;
|
||||
|
||||
// A narrow list, as on a phone or a half width card, fits about one item per
|
||||
// row, so two rows hide most of the legend. 450px is about the widest list a
|
||||
// phone viewport gives.
|
||||
const NARROW_MAX_ROWS = 3;
|
||||
const NARROW_WIDTH = 450;
|
||||
|
||||
// How many items fit in maxRows of a wrapping row, leaving room for the
|
||||
// "more" item after them when not all of them fit.
|
||||
export const fitInRows = (
|
||||
@@ -34,9 +40,10 @@ export const fitInRows = (
|
||||
return widths.length;
|
||||
};
|
||||
|
||||
// Fits the items of a wrapping legend list into two rows. The list keeps its
|
||||
// "more" item rendered with the legend-more class, and items past the fit
|
||||
// stay rendered out of the flow, so both can always be measured.
|
||||
// Fits the items of a wrapping legend list into two rows, three when it is
|
||||
// narrow. The list keeps its "more" item rendered with the legend-more class,
|
||||
// and items past the fit stay rendered out of the flow, so both can always be
|
||||
// measured.
|
||||
export class LegendFitController {
|
||||
// Items to show while collapsed, undefined until the list is measured
|
||||
public fit?: number;
|
||||
@@ -118,7 +125,7 @@ export class LegendFitController {
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
MAX_ROWS
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
this.fit = fit;
|
||||
|
||||
@@ -144,7 +144,8 @@ export class MediaProgressController implements ReactiveController {
|
||||
return;
|
||||
}
|
||||
slider.valueFormatter = formatMediaTime;
|
||||
if (slider.matches(":state(dragging)")) {
|
||||
// matches(":state(dragging)") throws before Chromium 125.
|
||||
if (slider.customStates.has("dragging")) {
|
||||
return;
|
||||
}
|
||||
slider.value = this.progress ?? 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The attributes that hold the address of a link or an image
|
||||
const URL_ATTRIBUTES = [
|
||||
export const URL_ATTRIBUTES = [
|
||||
["a", "href"],
|
||||
["img", "src"],
|
||||
["source", "src"],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeCssColor } from "../color/compute-color";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { stateColorCss } from "./state_color";
|
||||
|
||||
export const ALERT_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
@@ -8,8 +10,6 @@ export const ALERT_DOMAINS = [
|
||||
"lock",
|
||||
];
|
||||
|
||||
export const DEFAULT_ALERT_COLOR = "red";
|
||||
|
||||
export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
@@ -25,3 +25,16 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
|
||||
export const isAlertActive = (stateObj: HassEntity): boolean =>
|
||||
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
|
||||
|
||||
export const computeAlertColor = (
|
||||
stateObj: HassEntity,
|
||||
color: string
|
||||
): string | undefined => {
|
||||
if (color === "none") {
|
||||
return undefined;
|
||||
}
|
||||
if (color === "state") {
|
||||
return stateColorCss(stateObj);
|
||||
}
|
||||
return computeCssColor(color);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../../data/entity/entity_attributes";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
import type { WeatherEntity } from "../../data/weather";
|
||||
import { getWeatherUnit } from "../../data/weather";
|
||||
import type { HomeAssistant, ValuePart } from "../../types";
|
||||
@@ -115,6 +116,24 @@ export const computeAttributeValueToParts = (
|
||||
}
|
||||
}
|
||||
|
||||
// Date only, which is timezone agnostic, so parse and format it as
|
||||
// local time to keep the same calendar day
|
||||
if (isDate(attributeValue)) {
|
||||
const date = new Date(`${attributeValue}T00:00`);
|
||||
if (checkValidDate(date)) {
|
||||
return [
|
||||
{
|
||||
type: "value",
|
||||
value: formatDate(
|
||||
date,
|
||||
{ ...locale, time_zone: TimeZone.local },
|
||||
config
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Value was not a timestamp, so only do date formatting
|
||||
const date = new Date(attributeValue);
|
||||
if (checkValidDate(date)) {
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
// other one is built in the browser. The attribution comes from the TileJSON,
|
||||
// deliberately: it follows whoever serves the tiles.
|
||||
const SHIPPED_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
light: `${__STATIC_PATH__}map/light.json`,
|
||||
dark: `${__STATIC_PATH__}map/dark.json`,
|
||||
} as const;
|
||||
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
@@ -92,8 +92,12 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
return style;
|
||||
};
|
||||
|
||||
// The origin that serves the frontend's files: the page, except for the demo
|
||||
// embed on another site
|
||||
const filesBase = () => new URL(__STATIC_PATH__, location.href);
|
||||
|
||||
// MapLibre rejects a relative sprite URL
|
||||
const absoluteSprite = (url: string) => new URL(url, location.href).href;
|
||||
const absoluteSprite = (url: string) => new URL(url, filesBase()).href;
|
||||
|
||||
// Shared only while in flight, or a style that failed once would never be
|
||||
// fetched again. Text rather than a parsed style: MapLibre mutates what it
|
||||
@@ -141,14 +145,15 @@ export const loadStyle = async (
|
||||
|
||||
// Global to MapLibre; set once before the first map is created. Absolute,
|
||||
// because on Cast the page is not served from the instance. The URL comes
|
||||
// from the build: the worker is an entry of its own, next to the app.
|
||||
// from the build: the worker is an entry of its own, next to the app. MapLibre
|
||||
// starts a worker from another origin itself.
|
||||
let workerUrlSet = false;
|
||||
export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
|
||||
if (workerUrlSet) {
|
||||
return;
|
||||
}
|
||||
workerUrlSet = true;
|
||||
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
|
||||
setUrl(new URL(__MAPLIBRE_WORKER_URL__, filesBase()).href);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { finalizeMapStyle } from "./map-style-transforms";
|
||||
*/
|
||||
// Versioned: /static is served with a month of max-age, so a cached manifest
|
||||
// could otherwise pair a new build with the sprite sheet of an old one.
|
||||
const STYLE_URLS_PATH = `/static/map/urls.json?v=${__VERSION__}`;
|
||||
const STYLE_URLS_PATH = `${__STATIC_PATH__}map/urls.json?v=${__VERSION__}`;
|
||||
|
||||
let urls: Promise<OsmOptions["urls"]> | undefined;
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const POSITIONS: Record<
|
||||
bottomright: "bottom-right",
|
||||
};
|
||||
|
||||
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
|
||||
const MAPLIBRE_CSS_URL = `${__STATIC_PATH__}map/maplibre-gl.css`;
|
||||
// Fully hidden behind the globe, not MapLibre's faint ghost
|
||||
const MARKER_OPTIONS = { opacityWhenCovered: 0 };
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { provide, type ContextType } from "@lit/context";
|
||||
import { mdiCommentTextOutline } from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -17,9 +17,14 @@ import { getEntityTarget } from "../../panels/config/automation/target/get_entit
|
||||
import "../../panels/config/automation/target/ha-automation-row-targets";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
import { targetExpandGroupContext } from "../target-picker/target-expand-group-context";
|
||||
|
||||
@customElement("ha-automation-condition-summary")
|
||||
export class HaAutomationConditionSummary extends LitElement {
|
||||
// Triggers and conditions don't expand the groups their targets name.
|
||||
@provide({ context: targetExpandGroupContext })
|
||||
protected _targetExpandGroup = false;
|
||||
|
||||
@property() public label = "";
|
||||
|
||||
@property({ attribute: false }) public condition?: Condition;
|
||||
|
||||
@@ -89,6 +89,8 @@ export const DEFAULT_CHART_WIDTH = 500;
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
|
||||
export type ZoomSliderMode = "auto" | "always" | "never";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
@@ -163,9 +165,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "small-controls", type: Boolean })
|
||||
public smallControls?: boolean;
|
||||
|
||||
// For charts zoomed from outside, like the synced History charts
|
||||
@property({ attribute: "hide-zoom-controls", type: Boolean })
|
||||
public hideZoomControls = false;
|
||||
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
|
||||
// control, for charts zoomed from outside like the synced History charts.
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
// For charts sized to their content, like timelines, which grow to fit the
|
||||
// zoom slider instead of giving up plot height
|
||||
@@ -617,7 +620,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
>
|
||||
${
|
||||
(this._isZoomed || this._isGraphRoamed) &&
|
||||
!this.hideZoomControls &&
|
||||
this.zoomSlider !== "never" &&
|
||||
!showZoomSlider
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
@@ -1064,9 +1067,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
private _showZoomSlider() {
|
||||
const [start, end] = this._zoomRange;
|
||||
return (
|
||||
!this.hideZoomControls &&
|
||||
this.zoomSlider !== "never" &&
|
||||
this._hasZoomableXAxis() &&
|
||||
(this._zoomSliderHeld || start !== 0 || end !== 100)
|
||||
(this.zoomSlider === "always" ||
|
||||
this._zoomSliderHeld ||
|
||||
start !== 0 ||
|
||||
end !== 100)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2306,6 +2312,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
--ha-assist-chip-trailing-space: var(--ha-space-2);
|
||||
--ha-assist-chip-icon-label-space: var(--ha-space-1);
|
||||
}
|
||||
.zoom-reset {
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -648,7 +648,7 @@ export class HaNetworkGraph extends LitElement {
|
||||
|
||||
ha-icon-button,
|
||||
::slotted(ha-icon-button) {
|
||||
margin-right: 12px;
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import type { ZoomSliderMode } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import {
|
||||
@@ -105,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "hide-zoom-controls", type: Boolean })
|
||||
public hideZoomControls = false;
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@state() private _chartData: LineSeriesOption[] = [];
|
||||
|
||||
@@ -159,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
.expandLegend=${this.expandLegend}
|
||||
.hideZoomControls=${this.hideZoomControls}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
.clickLabelForMoreInfo=${this.clickForMoreInfo}
|
||||
@legend-label-click=${this._handleLegendLabelClick}
|
||||
></ha-chart-base>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../types";
|
||||
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import type { ZoomSliderMode } from "./ha-chart-base";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
@@ -68,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public chartIndex?;
|
||||
|
||||
@property({ attribute: "hide-zoom-controls", type: Boolean })
|
||||
public hideZoomControls = false;
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@state() private _chartData: CustomSeriesOption[] = [];
|
||||
|
||||
@@ -105,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
grow-for-zoom-slider
|
||||
@chart-click=${this._handleChartClick}
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
.hideZoomControls=${this.hideZoomControls}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
@chart-zoom-with-index=${this._handleTimelineSync}
|
||||
.height=${this.virtualize ? undefined : this.height}
|
||||
.expandLegend=${this.expandLegend}
|
||||
?hide-zoom-controls=${this.syncCharts}
|
||||
.zoomSlider=${this.syncCharts ? "never" : "auto"}
|
||||
></state-history-chart-line>
|
||||
</div> `;
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.clickForMoreInfo=${this.clickForMoreInfo}
|
||||
@y-width-changed=${this._yWidthChanged}
|
||||
@chart-zoom-with-index=${this._handleTimelineSync}
|
||||
?hide-zoom-controls=${this.syncCharts}
|
||||
.zoomSlider=${this.syncCharts ? "never" : "auto"}
|
||||
></state-history-chart-timeline>
|
||||
</div> `;
|
||||
};
|
||||
|
||||
@@ -352,6 +352,7 @@ export class HaChipBase extends HaButton {
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -29,9 +29,9 @@ import type {
|
||||
} from "../../common/translations/localize";
|
||||
import { formattersContext, registriesContext } from "../../data/context";
|
||||
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-button";
|
||||
import "../ha-button-toggle-group";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
@@ -121,6 +121,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@query("ha-button.add") private _addButton?: HTMLElement;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
connectedCallback(): void {
|
||||
@@ -159,7 +161,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
|
||||
${
|
||||
this.allowCustom
|
||||
? html`
|
||||
@@ -264,7 +266,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
<ha-chip-set
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
>
|
||||
${repeat(
|
||||
this._items,
|
||||
(item) => item,
|
||||
@@ -290,22 +294,16 @@ export class HaEntityNamePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${
|
||||
this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._localize(
|
||||
"ui.components.entity.entity-name-picker.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`
|
||||
}
|
||||
<ha-button
|
||||
class="add"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._addItem}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this._localize("ui.common.add")}
|
||||
</ha-button>
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
${this._renderRestoreButton()}
|
||||
@@ -373,6 +371,10 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
if (!value.length) {
|
||||
await this.updateComplete;
|
||||
this._addButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
@@ -629,7 +631,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -722,7 +724,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
padding: 0;
|
||||
margin: var(--ha-space-1) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export class HaAnsiToHtml extends LitElement {
|
||||
// The hidden lines each expand row shows when clicked.
|
||||
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
|
||||
|
||||
public get hasLines(): boolean {
|
||||
return !!this._pre?.childElementCount;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
AREA_CONTROLS_BUTTONS,
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
type AreaControlDomain,
|
||||
} from "../data/area/area_controls";
|
||||
import {
|
||||
@@ -148,15 +149,20 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
currentValue === id ||
|
||||
(excludeValues !== undefined && excludeValues.includes(id));
|
||||
|
||||
const areaEntities = getAreaEntityIds(
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
areaId,
|
||||
excludeEntities
|
||||
);
|
||||
const controlEntities = getAreaControlEntities(
|
||||
areaEntities,
|
||||
states,
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors,
|
||||
AREA_CONTROL_DOMAINS,
|
||||
areaId,
|
||||
excludeEntities
|
||||
AREA_CONTROL_DOMAINS
|
||||
);
|
||||
|
||||
const items: (AreaControlPickerItem | string)[] = [];
|
||||
|
||||
@@ -245,15 +245,15 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
|
||||
const newOrder: string[] = [];
|
||||
|
||||
for (const floorId of floorIds) {
|
||||
const isOnFloor = (areaId: string) =>
|
||||
(this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR) === floorId;
|
||||
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
|
||||
newHidden.push(...(value.hidden ?? []));
|
||||
// Reordering returns the hidden areas of every floor, not just this one
|
||||
newHidden.push(...(value.hidden ?? []).filter(isOnFloor));
|
||||
newOrder.push(...(value.order ?? []));
|
||||
continue;
|
||||
}
|
||||
const hidden = oldHidden.filter((areaId) => {
|
||||
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
|
||||
return id === floorId;
|
||||
});
|
||||
const hidden = oldHidden.filter(isOnFloor);
|
||||
if (hidden?.length) {
|
||||
newHidden.push(...hidden);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiPlus } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -26,6 +28,10 @@ import "./chips/ha-input-chip";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-input-helper-text";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
@@ -82,6 +88,8 @@ export class HaEntityTypePicker extends LitElement {
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states?: ContextType<typeof statesContext>;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _titlesLoaded = false;
|
||||
|
||||
protected willUpdate() {
|
||||
@@ -237,40 +245,13 @@ export class HaEntityTypePicker extends LitElement {
|
||||
const localize = this._i18n?.localize;
|
||||
const value = this.value ?? [];
|
||||
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
|
||||
|
||||
const addLabel = localize?.("ui.common.add");
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
${
|
||||
value.length && chipLabels
|
||||
? html`
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
value,
|
||||
(key) => key,
|
||||
(key) => {
|
||||
const label = chipLabels.get(key)!;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-key=${key}
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
@remove=${this._removeItem}
|
||||
selected
|
||||
>
|
||||
${this._renderIcon(key, "icon")} ${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.helper=${this.helper}
|
||||
.label=${this.label}
|
||||
.disabled=${this.disabled}
|
||||
.value=${""}
|
||||
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
|
||||
.getItems=${this._getItems}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.searchFn=${this._searchFn}
|
||||
@@ -278,10 +259,53 @@ export class HaEntityTypePicker extends LitElement {
|
||||
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
|
||||
no-sort
|
||||
@value-changed=${this._itemAdded}
|
||||
></ha-generic-picker>
|
||||
>
|
||||
<ha-chip-set
|
||||
slot="field"
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
>
|
||||
${repeat(
|
||||
value,
|
||||
(key) => key,
|
||||
(key) => {
|
||||
const label = chipLabels?.get(key) ?? key;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-key=${key}
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
@remove=${this._removeItem}
|
||||
selected
|
||||
>
|
||||
${this._renderIcon(key, "icon")} ${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${addLabel}
|
||||
</ha-button>
|
||||
</ha-chip-set>
|
||||
</ha-generic-picker>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _openPicker(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._picker?.open();
|
||||
}
|
||||
|
||||
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
|
||||
ev.stopPropagation();
|
||||
const key = ev.detail.value;
|
||||
@@ -320,7 +344,15 @@ export class HaEntityTypePicker extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2) 0;
|
||||
background-color: var(--mdc-text-field-fill-color);
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
|
||||
border-top-right-radius: var(--ha-border-radius-sm);
|
||||
border-top-left-radius: var(--ha-border-radius-sm);
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
ha-input-helper-text {
|
||||
padding: 0;
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ class HaMarkdownElement extends ReactiveElement {
|
||||
allowSvg: this.allowSvg,
|
||||
allowDataUrl: this.allowDataUrl,
|
||||
breaks: this.breaks,
|
||||
rewriteUrl: Boolean(this.rewriteUrl),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +100,7 @@ class HaMarkdownElement extends ReactiveElement {
|
||||
{
|
||||
allowSvg: this.allowSvg,
|
||||
allowDataUrl: this.allowDataUrl,
|
||||
allowRelativeUrls: Boolean(this.rewriteUrl),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
virtual-focus
|
||||
controlled
|
||||
tabindex="0"
|
||||
@mousedown=${this._preventBlur}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
>
|
||||
@@ -406,6 +407,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this.value
|
||||
)}
|
||||
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
|
||||
@mousedown=${this._preventBlur}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@ha-list-visibility-changed=${this._visibilityChanged}
|
||||
@@ -430,6 +432,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
.section-id=${section.id}
|
||||
.selected=${this._selectedSection === section.id}
|
||||
.label=${section.label}
|
||||
aria-label=${
|
||||
this.i18n?.localize?.(
|
||||
"ui.components.combo-box.filter_by_section",
|
||||
{ section: section.label }
|
||||
) || section.label
|
||||
}
|
||||
>
|
||||
</ha-filter-chip>`
|
||||
)}
|
||||
@@ -684,6 +692,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._resetListScroll();
|
||||
};
|
||||
|
||||
// Keep focus in the search field, so a focus change doesn't scroll the
|
||||
// list to the active row and make the click miss.
|
||||
private _preventBlur(ev: Event) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { SelectOption, SelectSelector } from "../../data/selector";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-checkbox";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-button";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
import "../ha-select-box";
|
||||
import "../ha-sortable";
|
||||
import "../ha-svg-icon";
|
||||
import "../radio/ha-radio-group";
|
||||
import "../radio/ha-radio-option";
|
||||
|
||||
@@ -28,10 +30,7 @@ import "../radio/ha-radio-option";
|
||||
export class HaSelectSelector extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
|
||||
transformer: ({ locale }) => locale,
|
||||
})
|
||||
private _locale!: FrontendLocaleData;
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
|
||||
@property({ attribute: false }) public selector!: SelectSelector;
|
||||
|
||||
@@ -48,6 +47,8 @@ export class HaSelectSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _itemMoved(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
@@ -83,7 +84,11 @@ export class HaSelectSelector extends LitElement {
|
||||
|
||||
if (this.selector.select?.sort) {
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
|
||||
caseInsensitiveStringCompare(
|
||||
a.label,
|
||||
b.label,
|
||||
this._i18n.locale.language
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,67 +163,80 @@ export class HaSelectSelector extends LitElement {
|
||||
const value =
|
||||
!this.value || this.value === "" ? [] : ensureArray(this.value);
|
||||
|
||||
const addLabel = this._i18n.localize("ui.common.add");
|
||||
|
||||
return html`
|
||||
${
|
||||
value?.length
|
||||
? html`
|
||||
<ha-sortable
|
||||
no-style
|
||||
.disabled=${!this.selector.select.reorder}
|
||||
@item-moved=${this._itemMoved}
|
||||
handle-selector="button.primary.action"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
value,
|
||||
(item) => item,
|
||||
(item, idx) => {
|
||||
const label =
|
||||
options.find((option) => option.value === item)
|
||||
?.label || item;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
.label=${label}
|
||||
.title=${label}
|
||||
selected
|
||||
>
|
||||
${
|
||||
this.selector.select?.reorder
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
options.find((option) => option.value === item)
|
||||
?.label || item
|
||||
}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
`
|
||||
this.label
|
||||
? html`<span id="label" class="label chips-label"
|
||||
>${this.label}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<ha-generic-picker
|
||||
no-sort
|
||||
.helper=${this.helper}
|
||||
.label=${this.label}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${""}
|
||||
.addButtonLabel=${this.label}
|
||||
.getItems=${this._getItems(options, value, true)}
|
||||
.allowCustomValue=${this.selector.select.custom_value ?? false}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
></ha-generic-picker>
|
||||
>
|
||||
<ha-sortable
|
||||
slot="field"
|
||||
no-style
|
||||
.disabled=${!this.selector.select.reorder}
|
||||
@item-moved=${this._itemMoved}
|
||||
draggable-selector="ha-input-chip"
|
||||
handle-selector="button.primary.action"
|
||||
>
|
||||
<ha-chip-set
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
>
|
||||
${repeat(
|
||||
value,
|
||||
(item) => item,
|
||||
(item, idx) => {
|
||||
const label =
|
||||
options.find((option) => option.value === item)?.label ||
|
||||
item;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
.label=${label}
|
||||
.title=${label}
|
||||
selected
|
||||
>
|
||||
${
|
||||
this.selector.select?.reorder
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${addLabel}
|
||||
</ha-button>
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -263,6 +281,11 @@ export class HaSelectSelector extends LitElement {
|
||||
: "";
|
||||
}
|
||||
|
||||
private _openPicker(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._picker?.open();
|
||||
}
|
||||
|
||||
private _getOptions = memoizeOne(
|
||||
(selector: SelectSelector) =>
|
||||
selector.select?.options?.map((option) =>
|
||||
@@ -403,19 +426,32 @@ export class HaSelectSelector extends LitElement {
|
||||
ha-dropdown-item[disabled] {
|
||||
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
|
||||
}
|
||||
ha-chip-set {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.chips-label {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
ha-sortable ha-chip-set {
|
||||
background-color: var(--mdc-text-field-fill-color);
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
|
||||
border-top-right-radius: var(--ha-border-radius-sm);
|
||||
border-top-left-radius: var(--ha-border-radius-sm);
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-select-box + ha-input-helper-text {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
ha-generic-picker + ha-input-helper-text {
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checkbox-list ha-input-helper-text {
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -98,8 +98,9 @@ export class HaUnitOfMeasurementSelector extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.required && units.length === 1 && units[0] !== null) {
|
||||
// Preselect the only allowed unit
|
||||
if (units.length === 1 && units[0] !== null) {
|
||||
// Preselect the only allowed unit; "no unit" is not allowed either, so
|
||||
// this also applies to an optional field
|
||||
fireEvent(this, "value-changed", { value: units[0] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,7 @@ import { domainToName } from "../data/integration";
|
||||
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import {
|
||||
extractFromTarget,
|
||||
getTargetComboBoxItemType,
|
||||
type ExtractFromTargetResult,
|
||||
type HassServiceTargetWithExclude,
|
||||
type TargetItem,
|
||||
type TargetType,
|
||||
@@ -70,21 +68,15 @@ import "./list/ha-list-base";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-item-row";
|
||||
import { showTargetDetailsDialog } from "./target-picker/dialog/show-dialog-target-details";
|
||||
import { filterTargetEntries } from "./target-picker/filter-target-entries";
|
||||
import { TargetExclusionsController } from "./target-picker/target-exclusions-controller";
|
||||
import {
|
||||
addToExclude,
|
||||
applyExclusionChanges,
|
||||
computeExclusionsPerTarget,
|
||||
hasIncludedTargets,
|
||||
removeFromExclude,
|
||||
resolveTargetItemEntities,
|
||||
TARGET_TYPES,
|
||||
targetItemKey,
|
||||
targetItems,
|
||||
withExclude,
|
||||
type ResolvedTargetItem,
|
||||
type TargetExclusion,
|
||||
type TargetExclusions,
|
||||
} from "./target-picker/target-exclusions";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
@@ -151,24 +143,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _excludeSection?: TargetTypeFloorless;
|
||||
|
||||
// What each exclusion takes out of each target, worked out from the value.
|
||||
@state() private _exclusions?: TargetExclusions;
|
||||
|
||||
// Whether every included target and every exclusion resolved, so it is
|
||||
// known what each exclusion takes out, which ones are shared with another
|
||||
// target, and the details can edit them.
|
||||
@state() private _exclusionsComplete = true;
|
||||
|
||||
// Every entity the included targets resolve to, so the exclude picker only
|
||||
// offers what would take something out.
|
||||
private _includedEntities?: string[];
|
||||
|
||||
private _resolveRequest = 0;
|
||||
|
||||
// What each target item resolves to, so a change only asks core about the
|
||||
// items it adds.
|
||||
private _itemEntries = new Map<string, Promise<ExtractFromTargetResult>>();
|
||||
|
||||
@state() private _replaceTarget?: TargetItem;
|
||||
|
||||
@state() private _replaceTargetAnchor?: HTMLElement;
|
||||
@@ -189,6 +163,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _newTarget?: TargetItem;
|
||||
|
||||
private _exclusions = new TargetExclusionsController(this);
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
@@ -302,9 +278,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
(devicesChanged ||
|
||||
this.hass.entities !== changedProps.get("hass")?.entities ||
|
||||
this.hass.areas !== changedProps.get("hass")?.areas);
|
||||
if (registriesChanged) {
|
||||
this._itemEntries.clear();
|
||||
}
|
||||
if (
|
||||
changedProps.has("value") ||
|
||||
changedProps.has("allowExclude") ||
|
||||
@@ -315,7 +288,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
changedProps.has("entityFilter") ||
|
||||
registriesChanged
|
||||
) {
|
||||
this._resolveExclusions();
|
||||
this._exclusions.resolve();
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -351,12 +324,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
? ensureArray(this.value?.label_id)
|
||||
: [];
|
||||
|
||||
const noEffect = this._exclusions?.noEffect ?? [];
|
||||
const unplaced = this._unplacedExclusions(
|
||||
this.value?.exclude,
|
||||
this._exclusions
|
||||
);
|
||||
const perTarget = this._exclusionsPerTarget();
|
||||
const noEffect = this._exclusions.exclusions?.noEffect ?? [];
|
||||
const unplaced = this._exclusions.unplaced;
|
||||
const perTarget = this._exclusions.perTarget;
|
||||
|
||||
if (
|
||||
!entityIds.length &&
|
||||
@@ -497,25 +467,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Excludes that aren't listed under a target or as having no effect,
|
||||
// because they are still being resolved or resolving them failed.
|
||||
private _unplacedExclusions = memoizeOne(
|
||||
(
|
||||
exclude: HassServiceTarget | undefined,
|
||||
exclusions: TargetExclusions | undefined
|
||||
): TargetItem[] => {
|
||||
const placed = new Set([
|
||||
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
|
||||
list.map(targetItemKey)
|
||||
),
|
||||
...(exclusions?.noEffect ?? []).map(targetItemKey),
|
||||
]);
|
||||
return targetItems(exclude).filter(
|
||||
(item) => !placed.has(targetItemKey(item))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _renderItems() {
|
||||
return html` ${this._renderValueGroups()} `;
|
||||
}
|
||||
@@ -612,155 +563,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
];
|
||||
}
|
||||
|
||||
private _extractItem(
|
||||
item: TargetItem,
|
||||
primaryEntitiesOnly: boolean
|
||||
): Promise<ExtractFromTargetResult> {
|
||||
const key = `${primaryEntitiesOnly}:${targetItemKey(item)}`;
|
||||
let entries = this._itemEntries.get(key);
|
||||
if (!entries) {
|
||||
entries = extractFromTarget(
|
||||
this.hass.callWS,
|
||||
{ [`${item.type}_id`]: [item.id] },
|
||||
false,
|
||||
primaryEntitiesOnly
|
||||
);
|
||||
this._itemEntries.set(key, entries);
|
||||
const cached = entries;
|
||||
cached.catch(() => {
|
||||
if (this._itemEntries.get(key) === cached) {
|
||||
this._itemEntries.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The entities an included item adds, filtered like its row so what an
|
||||
// exclusion takes out matches what the row counts.
|
||||
private async _resolveInclude(item: TargetItem): Promise<string[]> {
|
||||
if (item.type === "entity") {
|
||||
return resolveTargetItemEntities(this.hass, item);
|
||||
}
|
||||
const entries = await this._extractItem(
|
||||
item,
|
||||
this.primaryEntitiesOnly ?? true
|
||||
);
|
||||
return filterTargetEntries(
|
||||
this.hass.states,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
item,
|
||||
entries,
|
||||
{
|
||||
deviceFilter: this.deviceFilter,
|
||||
entityFilter: this.entityFilter,
|
||||
includeDomains: this.includeDomains,
|
||||
includeDeviceClasses: this.includeDeviceClasses,
|
||||
primaryEntitiesOnly: this.primaryEntitiesOnly,
|
||||
}
|
||||
).referenced_entities;
|
||||
}
|
||||
|
||||
// Like core, an exclude takes out every entity of an item, not only the
|
||||
// primary ones, and isn't narrowed by the filters.
|
||||
private async _resolveExclude(item: TargetItem): Promise<string[]> {
|
||||
if (item.type === "entity") {
|
||||
return resolveTargetItemEntities(this.hass, item);
|
||||
}
|
||||
return (await this._extractItem(item, false)).referenced_entities;
|
||||
}
|
||||
|
||||
private async _resolveExclusions() {
|
||||
const request = ++this._resolveRequest;
|
||||
this._includedEntities = undefined;
|
||||
const value = this.value;
|
||||
const excludes = targetItems(value?.exclude);
|
||||
const includes = targetItems(value);
|
||||
// Without an exclude, the targets are only needed to tell whether an
|
||||
// exclusion made in the details is shared with another target.
|
||||
if (
|
||||
!this.hass ||
|
||||
(!excludes.length && (!this.allowExclude || includes.length < 2))
|
||||
) {
|
||||
this._exclusions = undefined;
|
||||
this._exclusionsComplete = true;
|
||||
return;
|
||||
}
|
||||
// Until the targets resolve, the exclusions shown are from before and
|
||||
// may not say which ones are shared now.
|
||||
this._exclusionsComplete = false;
|
||||
|
||||
// Each item on its own, so it is known which exclusion takes out what
|
||||
// from which target. One that fails only loses its own part.
|
||||
const [includeResults, excludeResults] = await Promise.all([
|
||||
Promise.allSettled(includes.map((item) => this._resolveInclude(item))),
|
||||
Promise.allSettled(excludes.map((item) => this._resolveExclude(item))),
|
||||
]);
|
||||
if (request !== this._resolveRequest) {
|
||||
return;
|
||||
}
|
||||
const resolvedIncludes: ResolvedTargetItem[] = [];
|
||||
includes.forEach((item, index) => {
|
||||
const result = includeResults[index];
|
||||
if (result.status === "fulfilled") {
|
||||
resolvedIncludes.push({ ...item, entities: result.value });
|
||||
}
|
||||
});
|
||||
const resolvedExcludes: ResolvedTargetItem[] = [];
|
||||
excludes.forEach((item, index) => {
|
||||
const result = excludeResults[index];
|
||||
if (result.status === "fulfilled") {
|
||||
resolvedExcludes.push({ ...item, entities: result.value });
|
||||
}
|
||||
});
|
||||
const allIncluded = resolvedIncludes.length === includes.length;
|
||||
const exclusions = computeExclusionsPerTarget(
|
||||
resolvedIncludes,
|
||||
resolvedExcludes
|
||||
);
|
||||
if (!allIncluded) {
|
||||
// Without every target, no effect can't be told from not known, and a
|
||||
// shared exclusion can't be told from one that isn't.
|
||||
exclusions.noEffect = [];
|
||||
}
|
||||
this._exclusions = exclusions;
|
||||
this._exclusionsComplete =
|
||||
allIncluded && resolvedExcludes.length === excludes.length;
|
||||
this._includedEntities = allIncluded
|
||||
? [
|
||||
...new Set(
|
||||
includeResults.flatMap((result) =>
|
||||
result.status === "fulfilled" ? result.value : []
|
||||
)
|
||||
),
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Without an exclude yet, what the target includes is only needed once the
|
||||
// exclude picker opens.
|
||||
private async _resolveIncludedEntities() {
|
||||
const request = this._resolveRequest;
|
||||
try {
|
||||
const included = await Promise.all(
|
||||
targetItems(this.value).map((item) => this._resolveInclude(item))
|
||||
);
|
||||
if (request !== this._resolveRequest) {
|
||||
return;
|
||||
}
|
||||
this._includedEntities = [...new Set(included.flat())];
|
||||
this._excludePicker?.refreshItems();
|
||||
} catch (_err) {
|
||||
// The picker then offers everything instead.
|
||||
}
|
||||
}
|
||||
|
||||
private _openExcludePicker(ev: Event) {
|
||||
// exclude picker opens, so it only offers what would take something out.
|
||||
private async _openExcludePicker(ev: Event) {
|
||||
this._excludePicker?.open(ev);
|
||||
if (!this._includedEntities) {
|
||||
this._resolveIncludedEntities();
|
||||
if (!this._exclusions.includedEntities) {
|
||||
await this._exclusions.resolve({ force: true });
|
||||
this._excludePicker?.refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,27 +600,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _exclusionsPerTarget():
|
||||
Record<string, TargetExclusion[]> | undefined {
|
||||
return (
|
||||
this._exclusions?.perTarget ??
|
||||
(this.allowExclude && !hasIncludedTargets(this.value?.exclude)
|
||||
? {}
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
private _openDetails(ev: HASSDomEvent<HASSDomEvents["open-target-details"]>) {
|
||||
ev.stopPropagation();
|
||||
const { type, id, title } = ev.detail;
|
||||
const key = targetItemKey({ type, id });
|
||||
const perTarget = this._exclusionsPerTarget();
|
||||
const perTarget = this._exclusions.perTarget;
|
||||
const exclusions = perTarget ? (perTarget[key] ?? []) : undefined;
|
||||
const editable =
|
||||
exclusions &&
|
||||
this.allowExclude &&
|
||||
!this.disabled &&
|
||||
this._exclusionsComplete;
|
||||
this._exclusions.complete;
|
||||
showTargetDetailsDialog(this, {
|
||||
title,
|
||||
type,
|
||||
@@ -824,9 +623,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
primaryEntitiesOnly: this.primaryEntitiesOnly,
|
||||
exclusions,
|
||||
exclude: this.value?.exclude,
|
||||
otherTargetEntities: Object.entries(
|
||||
this._exclusions?.included ?? {}
|
||||
).flatMap(([targetKey, entities]) => (targetKey === key ? [] : entities)),
|
||||
expandGroup: this._exclusions.expandGroup,
|
||||
explainExclusion: editable
|
||||
? (item) => this._exclusions.explainExclusion(key, item)
|
||||
: undefined,
|
||||
exclusionsChanged: editable
|
||||
? (initial, current) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
@@ -888,7 +688,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this._excludeEntityFilter(this.entityFilter, this._includedEntities),
|
||||
this._excludeEntityFilter(
|
||||
this.entityFilter,
|
||||
this._exclusions.includedEntities
|
||||
),
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { css, html, LitElement, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import "../animation/ha-fade-in";
|
||||
import "./ha-skeleton-text";
|
||||
|
||||
// Varied widths so the placeholder reads as log lines rather than a block
|
||||
const LINE_WIDTHS = [72, 58, 86, 64, 44, 78, 52, 90, 60, 70];
|
||||
|
||||
const LINE_HEIGHT_EM = 1.5;
|
||||
|
||||
/**
|
||||
* Placeholder for log lines while a log loads. It covers its nearest
|
||||
* positioned ancestor and shows as many whole lines as fit, so it never
|
||||
* scrolls. Like the data table's loading rows, the label is given to assistive
|
||||
* technology rather than shown.
|
||||
*
|
||||
* @cssprop --ha-skeleton-log-padding - The space around the lines. Defaults to `var(--ha-space-2)`.
|
||||
*/
|
||||
@customElement("ha-skeleton-log")
|
||||
export class HaSkeletonLog extends LitElement {
|
||||
@property() public label?: string;
|
||||
|
||||
private _lineCount = new ResizeController(this, {
|
||||
callback: (entries) => {
|
||||
const height = entries[0]?.contentRect.height ?? 0;
|
||||
|
||||
const lineHeight =
|
||||
parseFloat(getComputedStyle(this).fontSize) * LINE_HEIGHT_EM;
|
||||
|
||||
return lineHeight ? Math.floor(height / lineHeight) : 0;
|
||||
},
|
||||
});
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-fade-in .duration=${300} easing="ease-in">
|
||||
<div role="progressbar" aria-label=${ifDefined(this.label)}>
|
||||
${Array.from(
|
||||
{ length: this._lineCount.value ?? 0 },
|
||||
(_, i) =>
|
||||
html`<div class="line">
|
||||
<ha-skeleton-text
|
||||
style=${styleMap({
|
||||
"--ha-skeleton-text-width": `${LINE_WIDTHS[i % LINE_WIDTHS.length]}%`,
|
||||
})}
|
||||
></ha-skeleton-text>
|
||||
</div>`
|
||||
)}
|
||||
</div>
|
||||
</ha-fade-in>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-skeleton-log-padding, var(--ha-space-2));
|
||||
overflow: hidden;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: ${unsafeCSS(LINE_HEIGHT_EM)}em;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-log": HaSkeletonLog;
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,7 @@ import "../../ha-svg-icon";
|
||||
import "../../list/ha-list-base";
|
||||
import "../ha-target-picker-item-row";
|
||||
import type { TargetExclusion } from "../target-exclusions";
|
||||
import {
|
||||
isShared,
|
||||
resolveItemExclusions,
|
||||
resolveTargetItemEntities,
|
||||
toggleTargetRow,
|
||||
} from "../target-exclusions";
|
||||
import { resolveItemExclusions, toggleTargetRow } from "../target-exclusions";
|
||||
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
|
||||
|
||||
@customElement("ha-dialog-target-details")
|
||||
@@ -54,10 +49,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
|
||||
private _exclusionsRequest = 0;
|
||||
|
||||
private _otherTargetEntities = memoizeOne(
|
||||
(entities?: string[]) => new Set(entities)
|
||||
);
|
||||
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(entities: HomeAssistant["entities"]) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities))
|
||||
@@ -83,7 +74,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
params.exclude,
|
||||
params.selector
|
||||
? params.selector.target?.primary_entities_only
|
||||
: params.primaryEntitiesOnly
|
||||
: params.primaryEntitiesOnly,
|
||||
params.expandGroup
|
||||
);
|
||||
} catch (_err) {
|
||||
// The details still show the target, only without what is excluded.
|
||||
@@ -292,37 +284,32 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
...ev.detail,
|
||||
exclusions: before,
|
||||
initial: this._initialExclusions,
|
||||
otherTargetEntities: this._otherTargetEntities(
|
||||
this._params?.otherTargetEntities
|
||||
),
|
||||
});
|
||||
this._exclusions
|
||||
.filter(
|
||||
(exclusion) =>
|
||||
exclusion.type !== "entity" &&
|
||||
!before.includes(exclusion) &&
|
||||
!this._initialExclusions.includes(exclusion)
|
||||
)
|
||||
.forEach((exclusion) => this._resolveRemoved(exclusion));
|
||||
.forEach((exclusion) => this._explainExclusion(exclusion));
|
||||
}
|
||||
|
||||
private async _resolveRemoved(exclusion: TargetExclusion) {
|
||||
let removed: string[];
|
||||
private async _explainExclusion(exclusion: TargetExclusion) {
|
||||
const explain = this._params?.explainExclusion;
|
||||
if (!explain) {
|
||||
return;
|
||||
}
|
||||
let explained: TargetExclusion | undefined;
|
||||
try {
|
||||
removed = await resolveTargetItemEntities(this.hass, exclusion, false);
|
||||
explained = await explain({ type: exclusion.type, id: exclusion.id });
|
||||
} catch (_err) {
|
||||
return;
|
||||
}
|
||||
if (!this._exclusions.includes(exclusion)) {
|
||||
if (!explained || !this._exclusions.includes(exclusion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shared = isShared(
|
||||
removed,
|
||||
this._otherTargetEntities(this._params?.otherTargetEntities)
|
||||
);
|
||||
this._exclusions = this._exclusions.map((current) =>
|
||||
current === exclusion ? { ...exclusion, removed, shared } : current
|
||||
current === exclusion ? explained : current
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
|
||||
import type { TargetSelector } from "../../../data/selector";
|
||||
import type { TargetType } from "../../../data/target";
|
||||
import type { TargetItem, TargetType } from "../../../data/target";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
|
||||
import type { TargetExclusion } from "../target-exclusions";
|
||||
|
||||
@@ -25,10 +25,16 @@ export interface TargetDetailsDialogParams {
|
||||
*/
|
||||
exclude?: HassServiceTarget;
|
||||
/**
|
||||
* The entities of the other included targets, so an exclusion made here
|
||||
* is known to also affect them.
|
||||
* Whether the target expands the groups it names when it is used, like an
|
||||
* action does and a trigger or condition doesn't. Defaults to `true`.
|
||||
*/
|
||||
otherTargetEntities?: string[];
|
||||
expandGroup?: boolean;
|
||||
/**
|
||||
* What excluding `item` would take out of this target, worked out by core
|
||||
* with the rest of the target value, so it is also known whether it
|
||||
* affects another target.
|
||||
*/
|
||||
explainExclusion?: (item: TargetItem) => Promise<TargetExclusion | undefined>;
|
||||
/**
|
||||
* Set to give the rows checkboxes. Called on apply with the exclusions the
|
||||
* dialog opened with and the ones it has now.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
mdiChartLine,
|
||||
mdiClose,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
@@ -57,6 +58,11 @@ import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { FloorRegistryEntry } from "../../data/floor_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import {
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import {
|
||||
deviceMeetsFilter,
|
||||
extractFromTarget,
|
||||
@@ -177,6 +183,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
|
||||
@state() private _entries?: ExtractFromTargetResult;
|
||||
|
||||
/** Metadata of an external statistic picked as entity, which has no state. */
|
||||
@state() private _statistic?: StatisticsMetaData;
|
||||
|
||||
private _fetchedEntries?: ExtractFromTargetResult;
|
||||
|
||||
private _lastFetch = 0;
|
||||
@@ -773,6 +782,19 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this._fetchedEntries = undefined;
|
||||
if (this.type === "entity") {
|
||||
this._entries = undefined;
|
||||
this._statistic = undefined;
|
||||
if (isExternalStatistic(this.itemId)) {
|
||||
try {
|
||||
const [metadata] = await getStatisticMetadata(this._api.callWS, [
|
||||
this.itemId,
|
||||
]);
|
||||
if (requestId === this._lastFetch) {
|
||||
this._statistic = metadata;
|
||||
}
|
||||
} catch {
|
||||
// Without metadata the statistic is shown as not found
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -859,6 +881,15 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
if (type === "entity") {
|
||||
const stateObject: HassEntity | undefined = this._states?.[item];
|
||||
// External statistics have no state, only the metadata the recorder keeps.
|
||||
if (!stateObject && this._statistic?.statistic_id === item) {
|
||||
return {
|
||||
name: this._statistic.name || item,
|
||||
context: domainToName(this._i18n.localize, this._statistic.source),
|
||||
fallbackIconPath: mdiChartLine,
|
||||
notFound: false,
|
||||
};
|
||||
}
|
||||
const { primary, secondary } = stateObject
|
||||
? computeEntityPickerDisplay(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
consumeContext,
|
||||
ContextController,
|
||||
} from "../../common/decorators/consume";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import {
|
||||
explainTarget,
|
||||
type HassServiceTargetWithExclude,
|
||||
type TargetItem,
|
||||
} from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import { filterTargetEntries } from "./filter-target-entries";
|
||||
import { targetExpandGroupContext } from "./target-expand-group-context";
|
||||
import {
|
||||
computeExclusionsPerTarget,
|
||||
hasIncludedTargets,
|
||||
targetItemKey,
|
||||
targetItems,
|
||||
type TargetExclusion,
|
||||
type TargetExclusions,
|
||||
type TargetNarrowing,
|
||||
} from "./target-exclusions";
|
||||
|
||||
export type TargetExclusionsHost = ReactiveControllerHost &
|
||||
HTMLElement & {
|
||||
hass: HomeAssistant;
|
||||
value?: HassServiceTargetWithExclude;
|
||||
allowExclude: boolean;
|
||||
primaryEntitiesOnly?: boolean;
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
includeDomains?: string[];
|
||||
includeDeviceClasses?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* What the exclusions of a target picker's value take out of each of its
|
||||
* targets, as core resolves them, narrowed to what the picker's rows show.
|
||||
*/
|
||||
export class TargetExclusionsController extends ContextController {
|
||||
/** What each exclusion takes out of each target, worked out from the value. */
|
||||
public exclusions?: TargetExclusions;
|
||||
|
||||
/**
|
||||
* Whether the exclusions belong to the current value, so it is known which
|
||||
* ones are shared with another target and the details can edit them.
|
||||
*/
|
||||
public complete = true;
|
||||
|
||||
private _host: TargetExclusionsHost;
|
||||
|
||||
@consumeContext({ context: targetExpandGroupContext, subscribe: true })
|
||||
private _expandGroup?: boolean;
|
||||
|
||||
private _request = 0;
|
||||
|
||||
constructor(host: TargetExclusionsHost) {
|
||||
super(host);
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
protected contextUpdated() {
|
||||
this.resolve();
|
||||
}
|
||||
|
||||
/** Whether the target expands the groups it names, like an action does. */
|
||||
public get expandGroup(): boolean {
|
||||
return this._expandGroup ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works the exclusions out again. Without an exclude, the targets are only
|
||||
* needed to tell whether an exclusion made in the details is shared with
|
||||
* another target, so they are only resolved for that or when `force`d.
|
||||
*/
|
||||
public async resolve({ force = false }: { force?: boolean } = {}) {
|
||||
const request = ++this._request;
|
||||
const { hass, value, allowExclude } = this._host;
|
||||
if (
|
||||
!hass ||
|
||||
!value ||
|
||||
(!force &&
|
||||
!hasIncludedTargets(value.exclude) &&
|
||||
(!allowExclude || targetItems(value).length < 2))
|
||||
) {
|
||||
this._update(undefined, true);
|
||||
return;
|
||||
}
|
||||
// Until the targets resolve, the exclusions shown are from before and
|
||||
// may not say which ones are shared now.
|
||||
this._update(this.exclusions, false);
|
||||
|
||||
let exclusions: TargetExclusions | undefined;
|
||||
try {
|
||||
exclusions = computeExclusionsPerTarget(
|
||||
await this._explain(value),
|
||||
this._keeps()
|
||||
);
|
||||
} catch (_err) {
|
||||
// Without an answer, the exclusions stay unplaced.
|
||||
}
|
||||
if (request === this._request) {
|
||||
this._update(exclusions, !!exclusions);
|
||||
}
|
||||
}
|
||||
|
||||
/** The exclusions by the target they affect, once there can be any. */
|
||||
public get perTarget(): Record<string, TargetExclusion[]> | undefined {
|
||||
return (
|
||||
this.exclusions?.perTarget ??
|
||||
(this._host.allowExclude && !hasIncludedTargets(this._host.value?.exclude)
|
||||
? {}
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** Every entity the targets include, once known for the current value. */
|
||||
public get includedEntities(): string[] | undefined {
|
||||
return this.complete ? this._includedEntities(this.exclusions) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes that aren't listed under a target or as having no effect,
|
||||
* because they are still being resolved or resolving them failed.
|
||||
*/
|
||||
public get unplaced(): TargetItem[] {
|
||||
return this._unplaced(this._host.value?.exclude, this.exclusions);
|
||||
}
|
||||
|
||||
/**
|
||||
* What excluding `item` on its own would take out of the target
|
||||
* `targetKey`, with the rest of the value as it is, and whether it affects
|
||||
* another target too.
|
||||
*/
|
||||
public async explainExclusion(
|
||||
targetKey: string,
|
||||
item: TargetItem
|
||||
): Promise<TargetExclusion | undefined> {
|
||||
const { value } = this._host;
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const explained = await this._explain({
|
||||
...value,
|
||||
exclude: { [`${item.type}_id`]: [item.id] },
|
||||
});
|
||||
return computeExclusionsPerTarget(explained, this._keeps()).perTarget[
|
||||
targetKey
|
||||
]?.[0];
|
||||
}
|
||||
|
||||
private _explain(target: HassServiceTargetWithExclude) {
|
||||
return explainTarget(
|
||||
this._host.hass.callWS,
|
||||
target,
|
||||
this.expandGroup,
|
||||
this._host.primaryEntitiesOnly ?? true
|
||||
);
|
||||
}
|
||||
|
||||
private _update(exclusions: TargetExclusions | undefined, complete: boolean) {
|
||||
this.exclusions = exclusions;
|
||||
this.complete = complete;
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
// What the picker's filters leave of a target, narrowed like the target's
|
||||
// row, so what an exclusion takes out matches what the row counts.
|
||||
private _keeps(): TargetNarrowing {
|
||||
const {
|
||||
hass,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
primaryEntitiesOnly,
|
||||
} = this._host;
|
||||
return (include) =>
|
||||
filterTargetEntries(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
include,
|
||||
{
|
||||
referenced_areas: include.referenced_areas,
|
||||
referenced_devices: include.referenced_devices,
|
||||
referenced_entities: include.entities,
|
||||
},
|
||||
{
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
primaryEntitiesOnly,
|
||||
}
|
||||
).referenced_entities;
|
||||
}
|
||||
|
||||
private _includedEntities = memoizeOne(
|
||||
(exclusions: TargetExclusions | undefined): string[] | undefined =>
|
||||
exclusions && [...new Set(Object.values(exclusions.included).flat())]
|
||||
);
|
||||
|
||||
private _unplaced = memoizeOne(
|
||||
(
|
||||
exclude: HassServiceTargetWithExclude["exclude"],
|
||||
exclusions: TargetExclusions | undefined
|
||||
): TargetItem[] => {
|
||||
const placed = new Set([
|
||||
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
|
||||
list.map(targetItemKey)
|
||||
),
|
||||
...(exclusions?.noEffect ?? []).map(targetItemKey),
|
||||
]);
|
||||
return targetItems(exclude).filter(
|
||||
(item) => !placed.has(targetItemKey(item))
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import {
|
||||
extractFromTarget,
|
||||
explainTarget,
|
||||
type ExtractFromTargetResultReferenced,
|
||||
type HassServiceTargetWithExclude,
|
||||
type TargetExplanation,
|
||||
type TargetIncludeEntities,
|
||||
type TargetItem,
|
||||
type TargetType,
|
||||
} from "../../data/target";
|
||||
@@ -34,30 +36,6 @@ export const targetItems = (target?: HassServiceTarget): TargetItem[] =>
|
||||
export const hasIncludedTargets = (target?: HassServiceTarget): boolean =>
|
||||
targetItems(target).length > 0;
|
||||
|
||||
/** A target item with the entities it resolves to. */
|
||||
export interface ResolvedTargetItem extends TargetItem {
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
/** The entities a single target item resolves to. */
|
||||
export const resolveTargetItemEntities = async (
|
||||
hass: HomeAssistant,
|
||||
item: TargetItem,
|
||||
primaryEntitiesOnly?: boolean
|
||||
): Promise<string[]> => {
|
||||
if (item.type === "entity") {
|
||||
return item.id === "all" ? Object.keys(hass.states) : [item.id];
|
||||
}
|
||||
return (
|
||||
await extractFromTarget(
|
||||
hass.callWS,
|
||||
{ [`${item.type}_id`]: [item.id] },
|
||||
false,
|
||||
primaryEntitiesOnly
|
||||
)
|
||||
).referenced_entities;
|
||||
};
|
||||
|
||||
/** An exclusion as seen from one included target. */
|
||||
export interface TargetExclusion extends TargetItem {
|
||||
/** Entities it takes out of that target. */
|
||||
@@ -76,26 +54,42 @@ export interface TargetExclusions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which exclusions take what out of each included target. Excludes are
|
||||
* global, so one exclusion is listed under every target it affects; one that
|
||||
* affects none, including one whose item no longer exists, has no effect.
|
||||
* The entities the picker's filters leave in an included target other than
|
||||
* an entity. An entity item is what it is, filters or not.
|
||||
*/
|
||||
export type TargetNarrowing = (include: TargetIncludeEntities) => string[];
|
||||
|
||||
/**
|
||||
* Which exclusions take what out of each included target, from what core
|
||||
* explained. Excludes are global, so one exclusion is listed under every
|
||||
* target it affects; one that affects none, including one whose item no
|
||||
* longer exists, has no effect. `keeps` narrows each target to what is left
|
||||
* of it once the picker's filters are applied; what an exclusion takes out
|
||||
* of it is narrowed the same way.
|
||||
*/
|
||||
export const computeExclusionsPerTarget = (
|
||||
includes: ResolvedTargetItem[],
|
||||
excludes: ResolvedTargetItem[]
|
||||
explained: TargetExplanation,
|
||||
keeps?: TargetNarrowing
|
||||
): TargetExclusions => {
|
||||
const included = Object.fromEntries(
|
||||
explained.includes.map((include) => [
|
||||
targetItemKey(include),
|
||||
include.type === "entity" || !keeps ? include.entities : keeps(include),
|
||||
])
|
||||
);
|
||||
const perTarget: Record<string, TargetExclusion[]> = {};
|
||||
const noEffect: TargetItem[] = [];
|
||||
|
||||
excludes.forEach((exclude) => {
|
||||
const excluded = new Set(exclude.entities);
|
||||
const hits = includes
|
||||
.map((include) => ({
|
||||
include,
|
||||
removed: [...new Set(include.entities)].filter((entityId) =>
|
||||
excluded.has(entityId)
|
||||
),
|
||||
}))
|
||||
explained.excludes.forEach((exclude) => {
|
||||
const hits = exclude.removed
|
||||
.map((target) => {
|
||||
const key = targetItemKey(target);
|
||||
const entities = new Set(included[key] ?? []);
|
||||
return {
|
||||
key,
|
||||
removed: target.entities.filter((entityId) => entities.has(entityId)),
|
||||
};
|
||||
})
|
||||
.filter(({ removed }) => removed.length > 0);
|
||||
|
||||
if (!hits.length) {
|
||||
@@ -103,8 +97,7 @@ export const computeExclusionsPerTarget = (
|
||||
return;
|
||||
}
|
||||
|
||||
hits.forEach(({ include, removed }) => {
|
||||
const key = targetItemKey(include);
|
||||
hits.forEach(({ key, removed }) => {
|
||||
if (!perTarget[key]) {
|
||||
perTarget[key] = [];
|
||||
}
|
||||
@@ -117,41 +110,58 @@ export const computeExclusionsPerTarget = (
|
||||
});
|
||||
});
|
||||
|
||||
const included = Object.fromEntries(
|
||||
includes.map((include) => [targetItemKey(include), include.entities])
|
||||
);
|
||||
// An action replaces a group it targets directly by its members, but the
|
||||
// group's row is the group itself. Once every member is out, so is the
|
||||
// group, and each exclusion counts it like its row does.
|
||||
explained.includes.forEach((include) => {
|
||||
const key = targetItemKey(include);
|
||||
const exclusions = perTarget[key];
|
||||
const members = included[key];
|
||||
if (
|
||||
include.type !== "entity" ||
|
||||
!exclusions ||
|
||||
!members.length ||
|
||||
members.includes(include.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const removed = new Set(
|
||||
exclusions.flatMap((exclusion) => exclusion.removed)
|
||||
);
|
||||
if (members.every((entityId) => removed.has(entityId))) {
|
||||
perTarget[key] = exclusions.map((exclusion) => ({
|
||||
...exclusion,
|
||||
removed: [include.id],
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return { perTarget, noEffect, included };
|
||||
};
|
||||
|
||||
/** The exclusions that take entities out of one item of a target. */
|
||||
/**
|
||||
* The exclusions that take entities out of one item of a target. Actions
|
||||
* expand the groups a target names, triggers and conditions don't, so
|
||||
* `expandGroup` follows what the target is used for.
|
||||
*/
|
||||
export const resolveItemExclusions = async (
|
||||
hass: HomeAssistant,
|
||||
item: TargetItem,
|
||||
exclude: HassServiceTarget | undefined,
|
||||
primaryEntitiesOnly?: boolean
|
||||
primaryEntitiesOnly?: boolean,
|
||||
expandGroup = true
|
||||
): Promise<TargetExclusion[]> => {
|
||||
const excludes = targetItems(exclude);
|
||||
if (!excludes.length) {
|
||||
if (!hasIncludedTargets(exclude)) {
|
||||
return [];
|
||||
}
|
||||
// Like core, an exclude takes out every entity of an item, not only the
|
||||
// primary ones.
|
||||
const [entities, excludeEntities] = await Promise.all([
|
||||
resolveTargetItemEntities(hass, item, primaryEntitiesOnly),
|
||||
Promise.all(
|
||||
excludes.map((exclusion) =>
|
||||
resolveTargetItemEntities(hass, exclusion, false)
|
||||
)
|
||||
),
|
||||
]);
|
||||
const explained = await explainTarget(
|
||||
hass.callWS,
|
||||
{ [`${item.type}_id`]: [item.id], exclude },
|
||||
expandGroup,
|
||||
primaryEntitiesOnly
|
||||
);
|
||||
return (
|
||||
computeExclusionsPerTarget(
|
||||
[{ ...item, entities }],
|
||||
excludes.map((exclusion, index) => ({
|
||||
...exclusion,
|
||||
entities: excludeEntities[index],
|
||||
}))
|
||||
).perTarget[targetItemKey(item)] ?? []
|
||||
computeExclusionsPerTarget(explained).perTarget[targetItemKey(item)] ?? []
|
||||
);
|
||||
};
|
||||
|
||||
@@ -369,7 +379,9 @@ export const targetRowState = (
|
||||
* excluded as such, including what is added to it later. The target can't
|
||||
* be excluded from itself, so unchecking it unchecks its rows instead.
|
||||
* `initial` gives back an exclusion that was there before as it was. A new
|
||||
* one is shared when it takes out any of `otherTargetEntities`.
|
||||
* one takes out the row's entities and counts as shared until core tells
|
||||
* what it takes out of every target, so a parent row can't take it away
|
||||
* before it is known whether it also affects another target.
|
||||
*/
|
||||
export const toggleTargetRow = ({
|
||||
row,
|
||||
@@ -377,14 +389,12 @@ export const toggleTargetRow = ({
|
||||
selected,
|
||||
exclusions,
|
||||
initial,
|
||||
otherTargetEntities,
|
||||
}: {
|
||||
row: TargetRow;
|
||||
isTarget: boolean;
|
||||
selected: boolean;
|
||||
exclusions: TargetExclusion[];
|
||||
initial: TargetExclusion[];
|
||||
otherTargetEntities?: ReadonlySet<string>;
|
||||
}): TargetExclusion[] => {
|
||||
if (!selected && isTarget) {
|
||||
return row.children
|
||||
@@ -397,7 +407,6 @@ export const toggleTargetRow = ({
|
||||
selected: false,
|
||||
exclusions: result,
|
||||
initial,
|
||||
otherTargetEntities,
|
||||
}),
|
||||
exclusions
|
||||
);
|
||||
@@ -417,19 +426,11 @@ export const toggleTargetRow = ({
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
removed: row.entities,
|
||||
shared: isShared(row.entities, otherTargetEntities),
|
||||
shared: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** Whether an exclusion taking out `removed` also affects another target. */
|
||||
export const isShared = (
|
||||
removed: string[],
|
||||
otherTargetEntities?: ReadonlySet<string>
|
||||
): boolean =>
|
||||
!!otherTargetEntities &&
|
||||
removed.some((entityId) => otherTargetEntities.has(entityId));
|
||||
|
||||
/**
|
||||
* The new `exclude` once the details are applied: what was taken away is
|
||||
* removed, what was added is added, and everything else stays as it was.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "@lit/context";
|
||||
|
||||
/**
|
||||
* Whether the targets below expand the groups they name when they are used.
|
||||
* Actions do, so that is what holds when nothing provides it. Triggers and
|
||||
* conditions don't, so their editors provide `false`.
|
||||
*/
|
||||
export const targetExpandGroupContext =
|
||||
createContext<boolean>("targetExpandGroup");
|
||||
@@ -420,11 +420,16 @@ export class HaTracePathDetails extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const targetSpec = this._getTargetSelector(config, type);
|
||||
// Triggers and conditions, including a condition action, don't expand
|
||||
// the groups their targets name.
|
||||
const expandGroup =
|
||||
type !== "trigger" && type !== "condition" && !("condition" in config);
|
||||
return html`<div class="targets">
|
||||
<ha-automation-row-targets
|
||||
.target=${target}
|
||||
.selector=${targetSpec ? { target: targetSpec } : undefined}
|
||||
.size=${size}
|
||||
.expandGroup=${expandGroup}
|
||||
interactive
|
||||
></ha-automation-row-targets>
|
||||
</div>`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
|
||||
import { generateEntityFilter } from "../../common/entity/entity_filter";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
@@ -61,15 +62,26 @@ export const AREA_CONTROLS_BUTTONS: Record<
|
||||
"cover-window": coverButton("window"),
|
||||
};
|
||||
|
||||
export const getAreaEntityIds = (
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areaId: string,
|
||||
excludeEntities?: string[]
|
||||
): string[] =>
|
||||
Object.keys(entities).filter(
|
||||
(entityId) =>
|
||||
getEntityAreaId(entityId, entities, devices) === areaId &&
|
||||
!excludeEntities?.includes(entityId)
|
||||
);
|
||||
|
||||
export const getAreaControlEntities = (
|
||||
areaEntities: string[],
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
controls: readonly AreaControlDomain[],
|
||||
areaId: string,
|
||||
excludeEntities: string[] | undefined
|
||||
controls: readonly AreaControlDomain[]
|
||||
): Record<AreaControlDomain, string[]> =>
|
||||
controls.reduce(
|
||||
(acc, control) => {
|
||||
@@ -81,15 +93,12 @@ export const getAreaControlEntities = (
|
||||
areas,
|
||||
floors,
|
||||
{
|
||||
area: areaId,
|
||||
entity_category: "none",
|
||||
...controlButton.filter,
|
||||
}
|
||||
);
|
||||
|
||||
acc[control] = Object.keys(entities).filter(
|
||||
(entityId) => filter(entityId) && !excludeEntities?.includes(entityId)
|
||||
);
|
||||
acc[control] = areaEntities.filter(filter);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AreaControlDomain, string[]>
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ export interface ESPHomeZWaveProxyCapabilities {
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
url: string;
|
||||
// `null` when no device is present (e.g. unpopulated USB port)
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
|
||||
+30
-3
@@ -13,7 +13,11 @@ import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isNumericSensorDeviceClass } from "./sensor";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
import type { Statistics } from "./recorder";
|
||||
import {
|
||||
isExternalStatistic,
|
||||
type Statistics,
|
||||
type StatisticsMetaData,
|
||||
} from "./recorder";
|
||||
|
||||
const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"];
|
||||
const NEED_ATTRIBUTE_DOMAINS = [
|
||||
@@ -517,11 +521,29 @@ const isNumericFromAttributes = (attributes: Record<string, any>) =>
|
||||
|
||||
const BLANK_UNIT = " ";
|
||||
|
||||
/**
|
||||
* Attributes for an external statistic, which has no entity. Without them the
|
||||
* history names the series after its ID and shows it without a unit.
|
||||
*/
|
||||
const statisticAttributes = (
|
||||
metadata: StatisticsMetaData | undefined
|
||||
): Record<string, string> => {
|
||||
const attributes: Record<string, string> = {};
|
||||
if (metadata?.name) {
|
||||
attributes.friendly_name = metadata.name;
|
||||
}
|
||||
if (metadata?.statistics_unit_of_measurement) {
|
||||
attributes.unit_of_measurement = metadata.statistics_unit_of_measurement;
|
||||
}
|
||||
return attributes;
|
||||
};
|
||||
|
||||
export const convertStatisticsToHistory = (
|
||||
hass: HomeAssistant,
|
||||
statistics: Statistics,
|
||||
statisticIds: string[],
|
||||
splitDeviceClasses = false
|
||||
splitDeviceClasses = false,
|
||||
statisticsMetadata?: Record<string, StatisticsMetaData>
|
||||
): HistoryResult => {
|
||||
// Maintain the statistic id ordering
|
||||
const orderedStatistics: Statistics = {};
|
||||
@@ -534,10 +556,15 @@ export const convertStatisticsToHistory = (
|
||||
// Convert statistics to HistoryResult format
|
||||
const statsHistoryStates: HistoryStates = {};
|
||||
Object.entries(orderedStatistics).forEach(([key, value]) => {
|
||||
// Other statistics keep a blank unit, so that they merge into the history
|
||||
// line of their entity even after the entity was removed.
|
||||
const attributes = isExternalStatistic(key)
|
||||
? statisticAttributes(statisticsMetadata?.[key])
|
||||
: {};
|
||||
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
|
||||
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
|
||||
lc: e.end / 1000,
|
||||
a: {},
|
||||
a: attributes,
|
||||
lu: e.end / 1000,
|
||||
}));
|
||||
statsHistoryStates[key] = entityHistoryStates;
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface RepositoryBase {
|
||||
|
||||
export interface RepositoryInfo extends RepositoryBase {
|
||||
additional_info: string;
|
||||
beta: boolean;
|
||||
default_branch: string;
|
||||
issues: number;
|
||||
releases: string[];
|
||||
@@ -98,6 +99,18 @@ export const uninstallMarketplaceRepository = (
|
||||
repository: repositoryId,
|
||||
});
|
||||
|
||||
// The latest version and the updates include pre-releases when on
|
||||
export const setMarketplaceRepositoryBeta = (
|
||||
hass: CallWS,
|
||||
repositoryId: string,
|
||||
showBeta: boolean
|
||||
) =>
|
||||
hass.callWS<null>({
|
||||
type: "marketplace/repository/beta",
|
||||
repository: repositoryId,
|
||||
show_beta: showBeta,
|
||||
});
|
||||
|
||||
// Fetches the information of the repository from GitHub again
|
||||
export const refreshMarketplaceRepository = (
|
||||
hass: CallWS,
|
||||
|
||||
@@ -76,6 +76,73 @@ export const extractFromTarget = async (
|
||||
);
|
||||
};
|
||||
|
||||
export interface TargetItemEntities extends TargetItem {
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
export interface TargetExcludeEffect extends TargetItem {
|
||||
removed: TargetItemEntities[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An included item with what it resolves to, and the areas and devices it
|
||||
* reaches on the way, so the picker's filters can narrow it like its row.
|
||||
*/
|
||||
export interface TargetIncludeEntities extends TargetItemEntities {
|
||||
referenced_areas: string[];
|
||||
referenced_devices: string[];
|
||||
}
|
||||
|
||||
export interface TargetExplanation {
|
||||
includes: TargetIncludeEntities[];
|
||||
excludes: TargetExcludeEffect[];
|
||||
}
|
||||
|
||||
export const explainTarget = (
|
||||
callWS: CallWS,
|
||||
target: HassServiceTargetWithExclude,
|
||||
expandGroup = true,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const request = {
|
||||
type: "explain_target" as const,
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
primary_entities_only: primaryEntitiesOnly,
|
||||
};
|
||||
|
||||
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
|
||||
callWS<TargetExplanation>(request)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What each item of an explained target is left with once every exclude is
|
||||
* taken out of it, by its `type:id`.
|
||||
*/
|
||||
export const targetEntitiesAfterExclude = (
|
||||
explanation: TargetExplanation
|
||||
): Record<string, string[]> => {
|
||||
const removed: Record<string, Set<string>> = {};
|
||||
explanation.excludes.forEach((exclude) =>
|
||||
exclude.removed.forEach(({ type, id, entities }) => {
|
||||
const key = `${type}:${id}`;
|
||||
removed[key] ??= new Set();
|
||||
entities.forEach((entityId) => removed[key].add(entityId));
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(
|
||||
explanation.includes.map(({ type, id, entities }) => {
|
||||
const key = `${type}:${id}`;
|
||||
const out = removed[key];
|
||||
return [
|
||||
key,
|
||||
out ? entities.filter((entityId) => !out.has(entityId)) : entities,
|
||||
];
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
|
||||
const tempTarget = {
|
||||
entity_id: target?.entity_id ? ensureArray(target?.entity_id) : [],
|
||||
|
||||
@@ -837,6 +837,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--ha-dialog-header-title-height: auto;
|
||||
}
|
||||
.dialog-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -690,7 +690,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
* is always in the scan, so a missing or absent port means it is offline.
|
||||
* Without the usb integration nothing is scanned, so a missing port says nothing.
|
||||
*/
|
||||
private _serialPortUsage(url: string) {
|
||||
private _serialPortUsage(url: string | null) {
|
||||
if (!this._serialPorts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class MoreInfoLogbook extends LitElement {
|
||||
.time=${this._time}
|
||||
.entityIds=${this._entityIdAsList(this.entityId)}
|
||||
name-detail="none"
|
||||
max-entries="100"
|
||||
narrow
|
||||
no-icon
|
||||
graph-color
|
||||
|
||||
@@ -375,21 +375,16 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.rows {
|
||||
gap: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--ha-space-4) var(--ha-space-1);
|
||||
align-items: center;
|
||||
}
|
||||
.row > *:first-child {
|
||||
flex: 1;
|
||||
margin-right: 4px;
|
||||
.row {
|
||||
display: contents;
|
||||
}
|
||||
.row ha-button {
|
||||
width: 82px;
|
||||
.row > :only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
ha-select {
|
||||
|
||||
@@ -122,7 +122,13 @@ export class MockBaseEntity {
|
||||
this.attributes = { ...this.attributes, ...changes.attributes };
|
||||
}
|
||||
|
||||
this.hass!.updateStates({
|
||||
// A removed or replaced entity can finish its transitions, so that their
|
||||
// timers stop, but must not bring its state back
|
||||
if (this.hass?.mockEntities[this.entityId] !== this) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hass.updateStates({
|
||||
[this.entityId]: this.toState(),
|
||||
});
|
||||
}
|
||||
@@ -159,7 +165,11 @@ export class MockBaseEntity {
|
||||
return {
|
||||
entity_id: this.entityId,
|
||||
state: this.state,
|
||||
// Every attribute of the input, like Core keeps the attributes of an
|
||||
// integration. What the mock works out itself wins, like a light that
|
||||
// is off having no brightness.
|
||||
attributes: {
|
||||
...this.attributes,
|
||||
...this._getBaseAttributes(),
|
||||
...this._getCapabilityAttributes(),
|
||||
...this._getStateAttributes(),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MockBaseEntity } from "./base-entity";
|
||||
import type { EntityAttributes } from "./types";
|
||||
|
||||
/**
|
||||
* Entity for domains without their own mock, such as sensor, weather and
|
||||
* calendar. Keeps all its attributes, as nothing here computes them.
|
||||
*/
|
||||
export class MockGenericEntity extends MockBaseEntity {
|
||||
protected _getStateAttributes(): EntityAttributes {
|
||||
return this.attributes;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MockBaseEntity } from "./base-entity";
|
||||
import type { MockBaseEntity } from "./base-entity";
|
||||
import type { EntityInput } from "./types";
|
||||
import { MockAlarmControlPanelEntity } from "./alarm-control-panel-entity";
|
||||
import { MockAutomationEntity } from "./automation-entity";
|
||||
import { MockClimateEntity } from "./climate-entity";
|
||||
import { MockCoverEntity } from "./cover-entity";
|
||||
import { MockFanEntity } from "./fan-entity";
|
||||
import { MockGenericEntity } from "./generic-entity";
|
||||
import { MockGroupEntity } from "./group-entity";
|
||||
import { MockHumidifierEntity } from "./humidifier-entity";
|
||||
import { MockInputNumberEntity } from "./input-number-entity";
|
||||
@@ -49,5 +50,6 @@ const TYPES: Record<string, EntityConstructor> = {
|
||||
|
||||
export const getEntity = (input: EntityInput): MockBaseEntity => {
|
||||
const [domain] = input.entity_id.split(".", 2);
|
||||
return new (TYPES[domain] || MockBaseEntity)(input);
|
||||
|
||||
return new (TYPES[domain] || MockGenericEntity)(input);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
HassEntity,
|
||||
HassEntityAttributeBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
|
||||
/** Base attributes type that all entities share */
|
||||
export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
|
||||
@@ -17,6 +18,11 @@ export type EntityInput = Pick<
|
||||
device_id?: string;
|
||||
/** Integration that provides the entity, defaults to "demo" */
|
||||
platform?: string;
|
||||
/**
|
||||
* Options of the entity in the mocked entity registry, such as timer
|
||||
* presets or favorite positions
|
||||
*/
|
||||
options?: EntityRegistryEntry["options"];
|
||||
/** When the state last changed, a random recent time by default */
|
||||
last_changed?: string;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
invalidateThemeCache,
|
||||
} from "../common/dom/apply_themes_on_element";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeEntityNameDisplayWithoutContext } from "../common/entity/compute_entity_name_display";
|
||||
import { computeFormatFunctions } from "../common/translations/entity-state";
|
||||
import { computeLocalize } from "../common/translations/localize";
|
||||
@@ -46,6 +47,7 @@ import { demoPanels } from "./demo_panels";
|
||||
import { demoServices } from "./demo_services";
|
||||
import { ENTITY_COMPONENT_ICONS } from "./entity_component_icons";
|
||||
import { getEntity } from "./entities/registry";
|
||||
import type { MockBaseEntity } from "./entities/base-entity";
|
||||
import type { EntityInput } from "./entities/types";
|
||||
|
||||
const ensureArray = <T>(val: T | T[]): T[] =>
|
||||
@@ -75,6 +77,8 @@ export interface MockHomeAssistant extends HomeAssistant {
|
||||
updateHass(obj: Partial<MockHomeAssistant>);
|
||||
updateStates(newStates: HassEntities);
|
||||
addEntities(entities: EntityInput | EntityInput[], replace?: boolean);
|
||||
// Recomputes the format functions, such as after the areas change.
|
||||
updateFormatFunctions(): Promise<void>;
|
||||
updateTranslations(fragment: null | string, language?: string);
|
||||
addTranslations(translations: Record<string, string>, language?: string);
|
||||
mockWS<T extends (...args) => any = any>(
|
||||
@@ -297,34 +301,77 @@ export const provideHass = (
|
||||
});
|
||||
}
|
||||
|
||||
// Like Core, an entity of a device has only its own name, which follows the
|
||||
// name of the device in its friendly name. An entity named like its device
|
||||
// has no name of its own.
|
||||
const registryName = (
|
||||
ent: MockBaseEntity
|
||||
): Pick<EntityRegistryDisplayEntry, "name" | "has_entity_name"> => {
|
||||
const friendlyName: string | undefined =
|
||||
ent.attributes.friendly_name || undefined;
|
||||
const device = ent.deviceId ? hass().devices[ent.deviceId] : undefined;
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
|
||||
if (
|
||||
!deviceName ||
|
||||
!friendlyName ||
|
||||
(friendlyName !== deviceName &&
|
||||
!friendlyName.startsWith(`${deviceName} `))
|
||||
) {
|
||||
return { name: friendlyName };
|
||||
}
|
||||
|
||||
return {
|
||||
name: friendlyName.slice(deviceName.length).trim() || undefined,
|
||||
has_entity_name: true,
|
||||
};
|
||||
};
|
||||
|
||||
function addEntities(
|
||||
newEntities: EntityInput | EntityInput[],
|
||||
replace = false
|
||||
) {
|
||||
if (replace) {
|
||||
// Drop the old mock entities too, so that services cannot bring them back.
|
||||
for (const entityId of Object.keys(entities)) {
|
||||
delete entities[entityId];
|
||||
}
|
||||
}
|
||||
|
||||
const states = {};
|
||||
|
||||
// A new object, so that registry consumers see the change
|
||||
const registry: HomeAssistant["entities"] = replace
|
||||
? {}
|
||||
: { ...hass().entities };
|
||||
ensureArray(newEntities).forEach((input) => {
|
||||
const ent = getEntity(input);
|
||||
ent.hass = hass();
|
||||
entities[ent.entityId] = ent;
|
||||
states[ent.entityId] = ent.toState();
|
||||
|
||||
hass().entities[ent.entityId] = {
|
||||
const name = registryName(ent);
|
||||
registry[ent.entityId] = {
|
||||
entity_id: ent.entityId,
|
||||
name: ent.attributes.friendly_name || undefined,
|
||||
...name,
|
||||
icon: undefined,
|
||||
platform: ent.platform ?? "demo",
|
||||
labels: [],
|
||||
area_id: ent.areaId,
|
||||
device_id: ent.deviceId,
|
||||
// Only follow the device when its name was split off, so a friendly
|
||||
// name that doesn't start with it is shown as it is
|
||||
next_name_part: ent.areaId
|
||||
? "area"
|
||||
: name.has_entity_name
|
||||
? "device"
|
||||
: undefined,
|
||||
} satisfies EntityRegistryDisplayEntry;
|
||||
});
|
||||
if (replace) {
|
||||
hass().updateHass({
|
||||
states,
|
||||
});
|
||||
} else {
|
||||
updateStates(states);
|
||||
}
|
||||
hass().updateHass({
|
||||
entities: registry,
|
||||
states: replace ? states : { ...hass().states, ...states },
|
||||
});
|
||||
|
||||
updateFormatFunctions();
|
||||
}
|
||||
@@ -347,15 +394,18 @@ export const provideHass = (
|
||||
|
||||
const localLanguage = getLocalLanguage();
|
||||
const noop = () => undefined;
|
||||
const auth = {
|
||||
data: {
|
||||
hassUrl: location.origin,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const hassObj: MockHomeAssistant = {
|
||||
// Home Assistant properties
|
||||
auth: {
|
||||
data: {
|
||||
hassUrl: location.origin,
|
||||
},
|
||||
} as any,
|
||||
auth,
|
||||
connection: {
|
||||
// The map reads the URL of Home Assistant from here
|
||||
options: { auth },
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
sendMessage: (msg) => {
|
||||
@@ -540,6 +590,7 @@ export const provideHass = (
|
||||
return hass().localize;
|
||||
},
|
||||
addEntities,
|
||||
updateFormatFunctions,
|
||||
mockWS(type, callback) {
|
||||
wsCommands[type] = callback;
|
||||
},
|
||||
|
||||
@@ -1064,6 +1064,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
align-items: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.search-toolbar ha-input-search {
|
||||
min-width: 0;
|
||||
}
|
||||
.filters {
|
||||
--mdc-text-field-fill-color: var(--input-fill-color);
|
||||
--mdc-text-field-idle-line-color: var(--input-idle-line-color);
|
||||
|
||||
@@ -180,6 +180,13 @@ export class HomeAssistantMain extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
/* Keep the negative z-index panel backgrounds (the resolver's ::before
|
||||
and the Lovelace view background) in this host's stacking context.
|
||||
Isolating the conditionally rendered partial-panel-resolver instead
|
||||
can intermittently leave the Android WebView (Chromium 109-124)
|
||||
accessibility tree with an empty panel subtree while the page paints
|
||||
normally, which breaks UI automation such as the companion app E2E. */
|
||||
isolation: isolate;
|
||||
--ha-sidebar-width: calc(56px + var(--safe-area-inset-left, 0px));
|
||||
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
|
||||
--safe-area-content-inset-left: 0px;
|
||||
@@ -201,7 +208,6 @@ export class HomeAssistantMain extends LitElement {
|
||||
partial-panel-resolver {
|
||||
display: block;
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
}
|
||||
partial-panel-resolver::before {
|
||||
content: "";
|
||||
|
||||
@@ -197,9 +197,10 @@ class OnboardingCoreConfig extends LitElement {
|
||||
longitude: this._location[1],
|
||||
elevation: Number(this._elevation),
|
||||
unit_system:
|
||||
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
|
||||
this._unitSystem ||
|
||||
(["US", "MM", "LR"].includes(this._country)
|
||||
? "us_customary"
|
||||
: "metric",
|
||||
: "metric"),
|
||||
time_zone: this._timeZone,
|
||||
currency: this._currency || countryCurrency[this._country] || "EUR",
|
||||
country: this._country,
|
||||
|
||||
@@ -942,6 +942,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
mdiStopCircleOutline,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import { provide } from "@lit/context";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { dump } from "js-yaml";
|
||||
@@ -29,6 +30,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import "../trigger/ha-automation-trigger-references";
|
||||
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
@@ -165,6 +167,11 @@ const getWaitTemplateCondition = (template: unknown): Condition | undefined =>
|
||||
|
||||
@customElement("ha-automation-action-row")
|
||||
export default class HaAutomationActionRow extends LitElement {
|
||||
// Conditions don't expand the groups their targets name; other actions do.
|
||||
@provide({ context: targetExpandGroupContext })
|
||||
@state()
|
||||
protected _targetExpandGroup = true;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public action!: Action;
|
||||
@@ -257,6 +264,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
return;
|
||||
}
|
||||
const type = getAutomationActionType(this.action);
|
||||
this._targetExpandGroup = type !== "condition";
|
||||
if (this._warnings && this._yamlMode && type) {
|
||||
this._warnings = getUiSupportWarnings(
|
||||
this.hass.localize,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
isDynamic,
|
||||
} from "../../../data/automation";
|
||||
import { getConditionDomain } from "../../../data/condition";
|
||||
import type { ConditionDescriptions } from "../../../data/condition";
|
||||
import {
|
||||
apiContext,
|
||||
conditionDescriptionsContext,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
getTriggerIcons,
|
||||
} from "../../../data/icons";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { DomainManifestLookup } from "../../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
|
||||
import {
|
||||
TARGET_SEPARATOR,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
type SingleHassServiceTarget,
|
||||
} from "../../../data/target";
|
||||
import { getTriggerDomain } from "../../../data/trigger";
|
||||
import type { TriggerDescriptions } from "../../../data/trigger";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||
import { haStyleScrollbar } from "../../../resources/styles";
|
||||
@@ -154,33 +157,29 @@ class DialogAddAutomationElement extends DialogMixin<AddAutomationElementDialogP
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@transform<HomeAssistant["states"], Set<string>>({
|
||||
transformer: function (this: DialogAddAutomationElement, states) {
|
||||
const domains = new Set(Object.keys(states).map(computeDomain));
|
||||
const entityIds = new Set(Object.keys(states));
|
||||
// keep the previous set, so a state change doesn't rerender the dialog
|
||||
return deepEqual(domains, this._usedDomains)
|
||||
? this._usedDomains
|
||||
: domains;
|
||||
return deepEqual(entityIds, this._entityIds)
|
||||
? this._entityIds
|
||||
: entityIds;
|
||||
},
|
||||
})
|
||||
private _usedDomains?: Set<string>;
|
||||
private _entityIds?: Set<string>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@transform<HomeAssistant["states"], SystemDomains | undefined>({
|
||||
transformer: function (this: DialogAddAutomationElement, states) {
|
||||
const systemDomains = this._calculateSystemDomains(states);
|
||||
// keep the previous result, so a state change doesn't rerender the dialog
|
||||
return deepEqual(systemDomains, this._systemDomains)
|
||||
? this._systemDomains
|
||||
: systemDomains;
|
||||
},
|
||||
watch: [
|
||||
"_manifests",
|
||||
"_triggerDescriptions",
|
||||
"_conditionDescriptions",
|
||||
"_registries",
|
||||
],
|
||||
})
|
||||
private _systemDomains?: SystemDomains;
|
||||
private get _usedDomains(): Set<string> | undefined {
|
||||
return this._computeUsedDomains(this._entityIds);
|
||||
}
|
||||
|
||||
private get _systemDomains(): SystemDomains | undefined {
|
||||
return this._calculateSystemDomains(
|
||||
this.params?.type,
|
||||
this._triggerDescriptions,
|
||||
this._conditionDescriptions,
|
||||
this._manifests,
|
||||
this._registries,
|
||||
this._entityIds
|
||||
);
|
||||
}
|
||||
|
||||
@state()
|
||||
@consume({ context: servicesContext, subscribe: true })
|
||||
@@ -884,32 +883,43 @@ class DialogAddAutomationElement extends DialogMixin<AddAutomationElementDialogP
|
||||
})
|
||||
);
|
||||
|
||||
private _calculateSystemDomains(
|
||||
states: HomeAssistant["states"]
|
||||
): SystemDomains | undefined {
|
||||
const type = this.params?.type;
|
||||
if (!type || type === "action" || !this._manifests || !this._registries) {
|
||||
return undefined;
|
||||
private _computeUsedDomains = memoizeOne(
|
||||
(entityIds: Set<string> | undefined) =>
|
||||
entityIds && new Set(Array.from(entityIds, computeDomain))
|
||||
);
|
||||
|
||||
private _calculateSystemDomains = memoizeOne(
|
||||
(
|
||||
type: AddAutomationElementDialogParams["type"] | undefined,
|
||||
triggerDescriptions: TriggerDescriptions,
|
||||
conditionDescriptions: ConditionDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
registries: ContextType<typeof registriesContext> | undefined,
|
||||
_entityIds: Set<string> | undefined
|
||||
): SystemDomains | undefined => {
|
||||
if (!type || type === "action" || !manifests || !registries) {
|
||||
return undefined;
|
||||
}
|
||||
const { entities, devices } = registries;
|
||||
return type === "trigger"
|
||||
? calculateActiveSystemDomains(
|
||||
triggerDescriptions,
|
||||
manifests,
|
||||
getTriggerDomain,
|
||||
this._states,
|
||||
entities,
|
||||
devices
|
||||
)
|
||||
: calculateActiveSystemDomains(
|
||||
conditionDescriptions,
|
||||
manifests,
|
||||
getConditionDomain,
|
||||
this._states,
|
||||
entities,
|
||||
devices
|
||||
);
|
||||
}
|
||||
const { entities, devices } = this._registries;
|
||||
return type === "trigger"
|
||||
? calculateActiveSystemDomains(
|
||||
this._triggerDescriptions,
|
||||
this._manifests,
|
||||
getTriggerDomain,
|
||||
states,
|
||||
entities,
|
||||
devices
|
||||
)
|
||||
: calculateActiveSystemDomains(
|
||||
this._conditionDescriptions,
|
||||
this._manifests,
|
||||
getConditionDomain,
|
||||
states,
|
||||
entities,
|
||||
devices
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _getAllItems = memoizeOne(getAllItems);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { provide } from "@lit/context";
|
||||
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from "../../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
|
||||
|
||||
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
|
||||
// when a condition has a `for:` duration, the recorder is only queried this far
|
||||
@@ -47,6 +49,10 @@ const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
|
||||
|
||||
@customElement("ha-automation-condition-platform")
|
||||
export class HaPlatformCondition extends LitElement {
|
||||
// Triggers and conditions don't expand the groups their targets name.
|
||||
@provide({ context: targetExpandGroupContext })
|
||||
protected _targetExpandGroup = false;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: PlatformCondition;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { transform } from "../../../../common/decorators/transform";
|
||||
@@ -34,6 +34,7 @@ import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import { showTargetDetailsDialog } from "../../../../components/target-picker/dialog/show-dialog-target-details";
|
||||
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import {
|
||||
apiContext,
|
||||
@@ -55,8 +56,10 @@ import {
|
||||
type TargetSelector,
|
||||
} from "../../../../data/selector";
|
||||
import {
|
||||
extractFromTarget,
|
||||
explainTarget,
|
||||
targetEntitiesAfterExclude,
|
||||
type HassServiceTargetWithExclude,
|
||||
type TargetExplanation,
|
||||
type TargetType,
|
||||
} from "../../../../data/target";
|
||||
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
@@ -81,6 +84,12 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@property({ reflect: true })
|
||||
public size: "s" | "m" = "m";
|
||||
|
||||
/**
|
||||
* Whether the target expands the groups it names. Overrides what the
|
||||
* context provides, for a place that shows targets of different kinds.
|
||||
*/
|
||||
@property({ attribute: false }) public expandGroup?: boolean;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
@@ -110,6 +119,11 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
// Whether the target expands the groups it names, like an action does.
|
||||
@state()
|
||||
@consume({ context: targetExpandGroupContext, subscribe: true })
|
||||
private _expandGroup?: boolean;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
@@ -118,29 +132,27 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
private _countCache = new Map<
|
||||
string,
|
||||
Promise<TargetCount | undefined> | TargetCount | undefined
|
||||
>();
|
||||
// What each target resolves to and what the exclude takes out, from core.
|
||||
// `null` once core couldn't tell.
|
||||
@state() private _explanation?: TargetExplanation | null;
|
||||
|
||||
// What the target's `exclude` resolves to, shared by every count.
|
||||
private _excludedEntities?: Promise<Set<string>>;
|
||||
private _explainRequest = 0;
|
||||
|
||||
// The same, once resolved, to mark excluded entity targets.
|
||||
@state() private _excludedEntityIds = new Set<string>();
|
||||
|
||||
private _rerenderCount = true;
|
||||
private get _resolvedExpandGroup(): boolean {
|
||||
return this.expandGroup ?? this._expandGroup ?? true;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("target") ||
|
||||
changedProps.has("selector") ||
|
||||
changedProps.has("interactive") ||
|
||||
changedProps.has("expandGroup") ||
|
||||
changedProps.has("_expandGroup") ||
|
||||
changedProps.has("_registries")
|
||||
) {
|
||||
this._rerenderCount = true;
|
||||
this._excludedEntities = undefined;
|
||||
this._resolveExcludedEntityIds();
|
||||
this._explain();
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -181,11 +193,6 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
this._rerenderCount = false;
|
||||
}
|
||||
|
||||
private _countMatchingEntities(referencedEntities: string[]): number {
|
||||
const targetSelector = this.selector;
|
||||
const hasEntityFilter = !!targetSelector?.target?.entity;
|
||||
@@ -237,102 +244,94 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private async _resolveExcludedEntityIds() {
|
||||
if (!this._api || !this._getExclude()) {
|
||||
this._excludedEntityIds = new Set();
|
||||
// The target without its templated ids, which only resolve when the
|
||||
// automation runs, so core can explain the rest.
|
||||
private _explainableTarget(): HassServiceTargetWithExclude | undefined {
|
||||
const { exclude: _exclude, ...target } = isConfigObject(this.target)
|
||||
? this.target
|
||||
: {};
|
||||
const explainable: HassServiceTargetWithExclude = {};
|
||||
Object.entries(target).forEach(([key, ids]) => {
|
||||
const plain = ensureArray(ids).filter((id) => !isTemplate(id));
|
||||
if (plain.length) {
|
||||
explainable[key] = plain;
|
||||
}
|
||||
});
|
||||
return Object.keys(explainable).length ? explainable : undefined;
|
||||
}
|
||||
|
||||
// Only targets other than entities show a count, and only when the summary
|
||||
// is interactive; without one of those, the explanation is only needed to
|
||||
// tell which entity targets the exclude takes out again.
|
||||
private async _explain() {
|
||||
const request = ++this._explainRequest;
|
||||
const target = this._explainableTarget();
|
||||
const exclude = this._getExclude();
|
||||
const counts =
|
||||
this.interactive &&
|
||||
!!target &&
|
||||
Object.keys(target).some((key) => key !== "entity_id");
|
||||
if (!this._api || !target || (!counts && !exclude)) {
|
||||
this._explanation = undefined;
|
||||
return;
|
||||
}
|
||||
const request = this._getExcludedEntities();
|
||||
const excluded = await request;
|
||||
// A newer target started its own lookup in the meantime.
|
||||
if (request === this._excludedEntities) {
|
||||
this._excludedEntityIds = excluded;
|
||||
}
|
||||
}
|
||||
|
||||
private _getExcludedEntities(): Promise<Set<string>> {
|
||||
const exclude = this._getExclude();
|
||||
if (!exclude) {
|
||||
return Promise.resolve(new Set());
|
||||
}
|
||||
if (!this._excludedEntities) {
|
||||
this._excludedEntities = extractFromTarget(
|
||||
let explanation: TargetExplanation | null;
|
||||
try {
|
||||
explanation = await explainTarget(
|
||||
this._api.callWS,
|
||||
exclude,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.then((result) => new Set(result.referenced_entities))
|
||||
// Counts then leave the exclude out rather than failing altogether.
|
||||
.catch(() => new Set<string>());
|
||||
{ ...target, exclude },
|
||||
this._resolvedExpandGroup,
|
||||
this.selector?.target?.primary_entities_only
|
||||
);
|
||||
} catch (_err) {
|
||||
explanation = null;
|
||||
}
|
||||
if (request === this._explainRequest) {
|
||||
this._explanation = explanation;
|
||||
}
|
||||
return this._excludedEntities;
|
||||
}
|
||||
|
||||
private _formatCount(count: TargetCount | undefined) {
|
||||
if (!count) {
|
||||
return " (-)";
|
||||
// What each target is left with once the exclude is taken out of it.
|
||||
private _remaining = memoizeOne(
|
||||
(explanation: TargetExplanation | null | undefined) =>
|
||||
explanation ? targetEntitiesAfterExclude(explanation) : undefined
|
||||
);
|
||||
|
||||
// The entity targets the exclude block takes out again entirely.
|
||||
private _excludedEntities = memoizeOne(
|
||||
(explanation: TargetExplanation | null | undefined): Set<string> => {
|
||||
const remaining = this._remaining(explanation);
|
||||
return new Set(
|
||||
explanation?.includes
|
||||
.filter(
|
||||
({ type, id, entities }) =>
|
||||
type === "entity" &&
|
||||
entities.length > 0 &&
|
||||
remaining![`entity:${id}`].length === 0
|
||||
)
|
||||
.map(({ id }) => id)
|
||||
);
|
||||
}
|
||||
return ` (${count.count})`;
|
||||
);
|
||||
|
||||
private _isExcludedEntity(entityId: string): boolean {
|
||||
return this._excludedEntities(this._explanation).has(entityId);
|
||||
}
|
||||
|
||||
private _renderCount(
|
||||
targetType: "floor" | "area" | "device" | "label",
|
||||
targetId: string
|
||||
) {
|
||||
const key = `${targetType}:${targetId}`;
|
||||
let fallback = " (-)";
|
||||
if (!this._countCache.has(key) || this._rerenderCount) {
|
||||
const cached = this._countCache.get(key);
|
||||
if (cached && !(cached instanceof Promise)) {
|
||||
fallback = this._formatCount(cached);
|
||||
}
|
||||
this._countCache.set(
|
||||
key,
|
||||
Promise.all([
|
||||
extractFromTarget(
|
||||
this._api.callWS,
|
||||
{
|
||||
[`${targetType}_id`]: [targetId],
|
||||
},
|
||||
false,
|
||||
this.selector?.target?.primary_entities_only
|
||||
),
|
||||
this._getExcludedEntities(),
|
||||
])
|
||||
.then(([result, excluded]): TargetCount => {
|
||||
// Excludes are global, so anything they resolve to is taken out
|
||||
// of this target too.
|
||||
const remaining = result.referenced_entities.filter(
|
||||
(entityId) => !excluded.has(entityId)
|
||||
);
|
||||
return { count: this._countMatchingEntities(remaining) };
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error counting target entities", err);
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
if (this._explanation === null) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const cached = this._countCache.get(key);
|
||||
if (cached instanceof Promise) {
|
||||
return until(
|
||||
cached.then((count) => {
|
||||
if (this._countCache.get(key) === cached) {
|
||||
this._countCache.set(key, count);
|
||||
}
|
||||
return count === undefined ? nothing : this._formatCount(count);
|
||||
}),
|
||||
fallback
|
||||
);
|
||||
const remaining = this._remaining(this._explanation)?.[
|
||||
`${targetType}:${targetId}`
|
||||
];
|
||||
if (!remaining) {
|
||||
return " (-)";
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
return this._formatCount(cached);
|
||||
}
|
||||
return nothing;
|
||||
return ` (${this._countMatchingEntities(remaining)})`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -368,7 +367,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
0
|
||||
);
|
||||
const excludedEntityTargets = ensureArray(target.entity_id ?? []).filter(
|
||||
(entityId) => this._excludedEntityIds.has(entityId)
|
||||
(entityId) => this._isExcludedEntity(entityId)
|
||||
).length;
|
||||
|
||||
if (totalLength <= 5) {
|
||||
@@ -553,9 +552,8 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
let badgeTargetId: string | undefined = targetId;
|
||||
let badgeTargetType: string | undefined = targetType;
|
||||
let countTemplate: unknown = nothing;
|
||||
// An entity target that the exclude block takes out again.
|
||||
const excluded =
|
||||
targetType === "entity" && this._excludedEntityIds.has(targetId);
|
||||
targetType === "entity" && this._isExcludedEntity(targetId);
|
||||
|
||||
if (targetType === "entity" && ["all", "none"].includes(targetId)) {
|
||||
icon = mdiShape;
|
||||
@@ -724,6 +722,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
itemId: targetId,
|
||||
selector: this.selector,
|
||||
exclude: this._getExclude(),
|
||||
expandGroup: this._resolvedExpandGroup,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -840,10 +839,6 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
interface TargetCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-row-targets": HaAutomationRowTargets;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { provide } from "@lit/context";
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import {
|
||||
mdiArrowDown,
|
||||
@@ -119,6 +120,7 @@ import "./types/ha-automation-trigger-webhook";
|
||||
import "./types/ha-automation-trigger-zone";
|
||||
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
|
||||
import { scrollRowIntoView } from "../scroll-row-into-view";
|
||||
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
|
||||
|
||||
export interface TriggerElement extends LitElement {
|
||||
trigger: Trigger;
|
||||
@@ -148,6 +150,10 @@ export const handleChangeEvent = (element: TriggerElement, ev: CustomEvent) => {
|
||||
|
||||
@customElement("ha-automation-trigger-row")
|
||||
export default class HaAutomationTriggerRow extends LitElement {
|
||||
// Triggers and conditions don't expand the groups their targets name.
|
||||
@provide({ context: targetExpandGroupContext })
|
||||
protected _targetExpandGroup = false;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public trigger!: Trigger;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { provide } from "@lit/context";
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "../../../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
|
||||
|
||||
const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
field.selector &&
|
||||
@@ -37,6 +39,10 @@ const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
// Triggers and conditions don't expand the groups their targets name.
|
||||
@provide({ context: targetExpandGroupContext })
|
||||
protected _targetExpandGroup = false;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public trigger!: PlatformTrigger;
|
||||
|
||||
@@ -429,9 +429,7 @@ class HaBlueprintOverview extends LitElement {
|
||||
></ha-icon-button>
|
||||
<ha-assist-chip
|
||||
slot="selection-bar"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.blueprint.overview.delete_selected"
|
||||
)}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.disabled=${this._usageCountsLoading || !this._selected.length}
|
||||
@click=${this._removeSelected}
|
||||
></ha-assist-chip>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import "./ai-task-pref";
|
||||
import "./mcp-pref";
|
||||
@@ -11,6 +13,26 @@ class HaConfigSectionAI extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._scrollToSection();
|
||||
}
|
||||
|
||||
private async _scrollToSection() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
await nextRender();
|
||||
// In the demo, the hash is the route of the page, not a section.
|
||||
const section = this.shadowRoot!.getElementById(hash);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
section.scrollIntoView();
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
@@ -24,7 +46,11 @@ class HaConfigSectionAI extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ai-task-pref>
|
||||
<mcp-pref .hass=${this.hass} .narrow=${this.narrow}></mcp-pref>
|
||||
<mcp-pref
|
||||
id="mcp"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></mcp-pref>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
|
||||
@@ -1645,6 +1645,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
|
||||
@@ -1923,8 +1923,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
ha-row-item.main-entity::part(supporting-text),
|
||||
ha-row-item.device-area::part(supporting-text) {
|
||||
ha-row-item::part(supporting-text),
|
||||
ha-list-item-button::part(supporting-text) {
|
||||
white-space: normal;
|
||||
}
|
||||
ha-row-item {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { HaLogViewer } from "../../../components/ha-log-viewer";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import "../../../components/skeleton/ha-skeleton-log";
|
||||
import {
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
@@ -51,6 +52,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
|
||||
@state() private _live = false;
|
||||
|
||||
// The backend answers the subscription once it has read the backlog
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@state() private _wrapLines = true;
|
||||
@@ -84,9 +88,11 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
(lines) => this._addLines(lines)
|
||||
);
|
||||
this._unsub = unsub;
|
||||
this._loading = true;
|
||||
unsub.then(
|
||||
() => {
|
||||
if (this._unsub === unsub) {
|
||||
this._loading = false;
|
||||
this._live = true;
|
||||
}
|
||||
},
|
||||
@@ -95,6 +101,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
return;
|
||||
}
|
||||
this._unsub = undefined;
|
||||
this._loading = false;
|
||||
if (err?.code === ERR_UNKNOWN_COMMAND) {
|
||||
this._unsupported = true;
|
||||
} else if (
|
||||
@@ -130,6 +137,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
// _subscribe reports subscription errors, so only ignore them here
|
||||
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsub = undefined;
|
||||
this._loading = false;
|
||||
this._live = false;
|
||||
}
|
||||
|
||||
@@ -209,12 +217,18 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
${
|
||||
this._lineCount
|
||||
? nothing
|
||||
: html`<div class="waiting">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_entry.live_log.waiting",
|
||||
{ integration: this.params.name }
|
||||
)}
|
||||
</div>`
|
||||
: this._loading
|
||||
? html`<ha-skeleton-log
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.logs.loading_log"
|
||||
)}
|
||||
></ha-skeleton-log>`
|
||||
: html`<div class="waiting">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_entry.live_log.waiting",
|
||||
{ integration: this.params.name }
|
||||
)}
|
||||
</div>`
|
||||
}
|
||||
</ha-log-viewer>
|
||||
${
|
||||
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user