mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-08 22:13:38 +00:00
Compare commits
14
Commits
20261028.4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32bc0696e | ||
|
|
6327304f4d | ||
|
|
11c79e09e9 | ||
|
|
5060b37381 | ||
|
|
63e3a04f15 | ||
|
|
818de1da8e | ||
|
|
70897feab1 | ||
|
|
f827c09e72 | ||
|
|
a4bb97b71c | ||
|
|
509561aabe | ||
|
|
0446eb5a62 | ||
|
|
e815c44a42 | ||
|
|
4551a87a7a | ||
|
|
23e376c7b4 |
No files matched your search
@@ -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,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -244,6 +244,9 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -1,21 +1,21 @@
|
||||
/*
|
||||
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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import "./workers";
|
||||
// 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.
|
||||
|
||||
+22
-141
@@ -1,139 +1,44 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { consume } from "../../../src/common/decorators/consume";
|
||||
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
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 { embedHassContext } from "./context";
|
||||
import { readScriptData } from "./script-data";
|
||||
import { embedThemeStyles } from "./theme-styles";
|
||||
|
||||
const pageProvider = (): HTMLElement =>
|
||||
document.querySelector("ha-embed-provider") ??
|
||||
document.body.appendChild(document.createElement("ha-embed-provider"));
|
||||
|
||||
// Looks past shadow roots, so a card 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;
|
||||
};
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a Lovelace card. The card 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 card adds an empty one.
|
||||
* 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 LitElement {
|
||||
/**
|
||||
* The card configuration. Also read from a JSON or YAML script child, see
|
||||
* `readScriptData`.
|
||||
*/
|
||||
@property({ type: Object }) public config?: LovelaceCardConfig;
|
||||
|
||||
/** The ID of the `ha-embed-provider` to use. */
|
||||
@property() public provider?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: embedHassContext, subscribe: true })
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
// A provider that is not an ancestor, which gets the context requests of the
|
||||
// card and its content. Context requests only reach ancestors by themselves.
|
||||
private _linkedProvider?: HTMLElement;
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
private _card = createRef<HuiCard>();
|
||||
|
||||
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 node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
|
||||
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
|
||||
}
|
||||
});
|
||||
// A card names its type
|
||||
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
|
||||
return "type" in data && typeof data.type === "string";
|
||||
}
|
||||
|
||||
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 card keeps a config set since
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const config = readScriptData(this) as LovelaceCardConfig | undefined;
|
||||
if (config) {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._hass || !this.config) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<div
|
||||
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
|
||||
dir=${computeRTLDirection(this._hass)}
|
||||
>
|
||||
<hui-card
|
||||
${ref(this._card)}
|
||||
.hass=${this._hass}
|
||||
.config=${this.config}
|
||||
.layout=${"grid"}
|
||||
@card-updated=${this._updateSize}
|
||||
@card-visibility-changed=${this._updateSize}
|
||||
></hui-card>
|
||||
</div>`;
|
||||
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() {
|
||||
@@ -163,7 +68,7 @@ export class HaEmbedCard extends LitElement {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
embedThemeStyles,
|
||||
embedElementStyles,
|
||||
css`
|
||||
/* The sizes of a card in a section, see hui-grid-section */
|
||||
:host {
|
||||
@@ -176,9 +81,6 @@ export class HaEmbedCard extends LitElement {
|
||||
:host([full-width]) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
:host([fit-rows]) .theme {
|
||||
height: calc(
|
||||
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
|
||||
@@ -186,27 +88,6 @@ export class HaEmbedCard extends LitElement {
|
||||
)
|
||||
);
|
||||
}
|
||||
/* The text styles the app sets on html and body, so that the card 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 card */
|
||||
.theme[dir="rtl"] {
|
||||
--direction: rtl;
|
||||
--float-start: right;
|
||||
--float-end: left;
|
||||
--scale-direction: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
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";
|
||||
@@ -40,6 +43,7 @@ import { readScriptData } from "./script-data";
|
||||
interface EmbedProviderData {
|
||||
entities?: EntityInput[];
|
||||
areas?: DemoArea[];
|
||||
devices?: DemoDevice[];
|
||||
calendars?: Record<string, CalendarEventApiData[]>;
|
||||
energy?: Partial<EnergyPreferences>;
|
||||
}
|
||||
@@ -54,8 +58,8 @@ const areaName = (areaId: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
|
||||
* inside it. Cards in the same provider share their entities.
|
||||
* 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 {
|
||||
@@ -67,13 +71,19 @@ export class HaEmbedProvider extends LitElement {
|
||||
* 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`, `calendars` and `energy`.
|
||||
* `areas`, `devices`, `calendars` and `energy`.
|
||||
*/
|
||||
@property({ type: Array }) public entities: EntityInput[] = [];
|
||||
|
||||
/** All areas. Without them, an area for each `area_id` of the entities. */
|
||||
/**
|
||||
* 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.
|
||||
@@ -155,7 +165,11 @@ export class HaEmbedProvider extends LitElement {
|
||||
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.areas)
|
||||
);
|
||||
// The registry of the entities, without settings such as calendar colors
|
||||
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 => ({
|
||||
@@ -166,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
|
||||
}),
|
||||
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) =>
|
||||
@@ -222,6 +239,7 @@ export class HaEmbedProvider extends LitElement {
|
||||
} else if (data) {
|
||||
this.entities = data.entities ?? [];
|
||||
this.areas = data.areas;
|
||||
this.devices = data.devices;
|
||||
this.calendars = data.calendars;
|
||||
this.energy = data.energy;
|
||||
}
|
||||
@@ -238,14 +256,19 @@ export class HaEmbedProvider extends LitElement {
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
if (changedProps.has("entities") || changedProps.has("areas")) {
|
||||
if (
|
||||
changedProps.has("entities") ||
|
||||
changedProps.has("areas") ||
|
||||
changedProps.has("devices")
|
||||
) {
|
||||
const devices = this.devices ?? [];
|
||||
const areas: DemoArea[] =
|
||||
this.areas ??
|
||||
[
|
||||
...new Set(
|
||||
this.entities
|
||||
.map((entity) => entity.area_id)
|
||||
.filter((areaId) => areaId !== undefined)
|
||||
[...this.entities, ...devices].flatMap((item) =>
|
||||
item.area_id === undefined ? [] : [item.area_id]
|
||||
)
|
||||
),
|
||||
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
|
||||
|
||||
@@ -253,11 +276,16 @@ export class HaEmbedProvider extends LitElement {
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = toAreaRegistryEntry(area);
|
||||
});
|
||||
// Before the entities, so that their names can include the area
|
||||
this.hass.updateHass({ areas: areasById });
|
||||
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
|
||||
// change
|
||||
// or devices change
|
||||
if (changedProps.has("entities")) {
|
||||
this.hass.addEntities(this.entities, true);
|
||||
} else {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -15,3 +15,17 @@ export const readScriptData = (element: HTMLElement): unknown => {
|
||||
? 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;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unsafeCSS } from "lit";
|
||||
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";
|
||||
@@ -59,3 +59,37 @@ 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -108,15 +108,24 @@
|
||||
against a mocked backend in the browser. No Home Assistant server is
|
||||
necessary.
|
||||
</p>
|
||||
<p>There are two components:</p>
|
||||
<p>
|
||||
<code><ha-embed-provider></code> holds the mocked backend and its
|
||||
entities. These components render dashboard content with the backend of
|
||||
a provider:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code><ha-embed-card></code> renders one card.</li>
|
||||
<li>
|
||||
<code><ha-embed-provider></code> holds the mocked backend and
|
||||
its entities.
|
||||
<code><ha-embed-section></code> renders one section, or a
|
||||
section strategy.
|
||||
</li>
|
||||
<li>
|
||||
<code><ha-embed-card></code> renders one card. It uses the
|
||||
backend of a provider.
|
||||
<code><ha-embed-view></code> renders one view, a view strategy,
|
||||
or one view of a dashboard.
|
||||
</li>
|
||||
<li>
|
||||
<code><ha-embed-dashboard></code> renders a dashboard, or a
|
||||
dashboard strategy, with its tabs.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -324,6 +333,193 @@
|
||||
</script><!-- htmlmin:ignore -->
|
||||
</ha-embed-card>
|
||||
</div>
|
||||
<p>
|
||||
The other components find their provider the same way, and take the
|
||||
same <code>provider</code> attribute.
|
||||
</p>
|
||||
|
||||
<h2>Sections</h2>
|
||||
<p>
|
||||
<code><ha-embed-section></code> renders a section the same way as
|
||||
in a sections view. The configuration is the same as in a dashboard,
|
||||
so headings span the top, and a <code>strategy</code> generates the
|
||||
section. The section takes the width of the component.
|
||||
</p>
|
||||
<pre><code><ha-embed-section>
|
||||
<script type="text/yaml">
|
||||
cards:
|
||||
- type: heading
|
||||
heading: Kitchen
|
||||
- type: tile
|
||||
entity: light.kitchen
|
||||
- type: tile
|
||||
entity: switch.kettle
|
||||
</script>
|
||||
</ha-embed-section></code></pre>
|
||||
<ha-embed-provider>
|
||||
<!-- htmlmin:ignore --><script type="text/yaml">
|
||||
entities:
|
||||
- entity_id: light.kitchen_ceiling
|
||||
state: "on"
|
||||
device_id: kitchen_ceiling
|
||||
attributes:
|
||||
friendly_name: Ceiling
|
||||
supported_color_modes: [brightness]
|
||||
color_mode: brightness
|
||||
brightness: 200
|
||||
- entity_id: switch.kettle
|
||||
state: "off"
|
||||
area_id: kitchen
|
||||
attributes:
|
||||
friendly_name: Kettle
|
||||
device_class: outlet
|
||||
- entity_id: sensor.kitchen_temperature
|
||||
state: "21.4"
|
||||
area_id: kitchen
|
||||
attributes:
|
||||
friendly_name: Kitchen temperature
|
||||
device_class: temperature
|
||||
state_class: measurement
|
||||
unit_of_measurement: °C
|
||||
devices:
|
||||
- id: kitchen_ceiling
|
||||
name: Kitchen ceiling light
|
||||
manufacturer: Acme
|
||||
model: Smart bulb
|
||||
area_id: kitchen
|
||||
</script><!-- htmlmin:ignore -->
|
||||
<div class="single">
|
||||
<ha-embed-section>
|
||||
<!-- htmlmin:ignore --><script type="text/yaml">
|
||||
cards:
|
||||
- type: heading
|
||||
heading: Kitchen
|
||||
- type: tile
|
||||
entity: light.kitchen_ceiling
|
||||
tap_action:
|
||||
action: toggle
|
||||
- type: tile
|
||||
entity: switch.kettle
|
||||
tap_action:
|
||||
action: toggle
|
||||
- type: tile
|
||||
entity: sensor.kitchen_temperature
|
||||
grid_options:
|
||||
columns: 12
|
||||
</script><!-- htmlmin:ignore -->
|
||||
</ha-embed-section>
|
||||
</div>
|
||||
</ha-embed-provider>
|
||||
|
||||
<h2>Views</h2>
|
||||
<p>
|
||||
<code><ha-embed-view></code> renders a view, with its badges and
|
||||
layout. A <code>strategy</code> generates the view. Give the component
|
||||
the width the view needs: masonry and sidebar views pick their columns
|
||||
from the width of the page, not the component.
|
||||
</p>
|
||||
<pre><code><ha-embed-view>
|
||||
<script type="text/yaml">
|
||||
type: sections
|
||||
badges:
|
||||
- entity: sensor.outside_temperature
|
||||
sections:
|
||||
- cards: …
|
||||
</script>
|
||||
</ha-embed-view></code></pre>
|
||||
<p>
|
||||
To show one view of a dashboard, or of a dashboard strategy, put the
|
||||
dashboard in <code>dashboard</code> and the path or index of the view
|
||||
in <code>view</code>. Without <code>view</code>, the first view shows.
|
||||
</p>
|
||||
<pre><code><ha-embed-view>
|
||||
<script type="text/yaml">
|
||||
dashboard:
|
||||
strategy:
|
||||
type: custom:my-dashboard
|
||||
view: overview
|
||||
</script>
|
||||
</ha-embed-view></code></pre>
|
||||
<ha-embed-view provider="shared">
|
||||
<!-- htmlmin:ignore --><script type="text/yaml">
|
||||
type: sections
|
||||
max_columns: 2
|
||||
badges:
|
||||
- entity: sensor.outside_temperature
|
||||
sections:
|
||||
- cards:
|
||||
- type: heading
|
||||
heading: Living room
|
||||
- type: tile
|
||||
entity: light.living_room
|
||||
tap_action:
|
||||
action: toggle
|
||||
- type: tile
|
||||
entity: switch.coffee_maker
|
||||
tap_action:
|
||||
action: toggle
|
||||
- cards:
|
||||
- type: heading
|
||||
heading: Hallway
|
||||
- type: thermostat
|
||||
entity: climate.hallway
|
||||
</script><!-- htmlmin:ignore -->
|
||||
</ha-embed-view>
|
||||
|
||||
<h2>Dashboards</h2>
|
||||
<p>
|
||||
<code><ha-embed-dashboard></code> renders a dashboard with its
|
||||
tabs. A <code>strategy</code> generates the dashboard. Each dashboard
|
||||
keeps its own tab, so switching tabs does not change the address of
|
||||
your page. The dashboard scrolls inside the component, under its
|
||||
toolbar. The component is 600 pixels high; set its
|
||||
<code>height</code> to change that. Set <code>view</code> to the path
|
||||
or index of the first view to show.
|
||||
</p>
|
||||
<pre><code><ha-embed-dashboard style="height: 480px">
|
||||
<script type="text/yaml">
|
||||
views:
|
||||
- title: Home
|
||||
path: home
|
||||
cards: …
|
||||
- title: Climate
|
||||
path: climate
|
||||
cards: …
|
||||
</script>
|
||||
</ha-embed-dashboard></code></pre>
|
||||
<ha-embed-dashboard provider="shared" style="height: 480px">
|
||||
<!-- htmlmin:ignore --><script type="text/yaml">
|
||||
views:
|
||||
- title: Home
|
||||
path: home
|
||||
type: sections
|
||||
max_columns: 2
|
||||
sections:
|
||||
- cards:
|
||||
- type: heading
|
||||
heading: Living room
|
||||
- type: tile
|
||||
entity: light.living_room
|
||||
tap_action:
|
||||
action: toggle
|
||||
- type: tile
|
||||
entity: switch.coffee_maker
|
||||
tap_action:
|
||||
action: toggle
|
||||
- cards:
|
||||
- type: heading
|
||||
heading: Outside
|
||||
- type: tile
|
||||
entity: sensor.outside_temperature
|
||||
- title: Climate
|
||||
path: climate
|
||||
type: sections
|
||||
sections:
|
||||
- cards:
|
||||
- type: thermostat
|
||||
entity: climate.hallway
|
||||
</script><!-- htmlmin:ignore -->
|
||||
</ha-embed-dashboard>
|
||||
|
||||
<h2>Light and dark mode</h2>
|
||||
<p>
|
||||
@@ -375,7 +571,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =>
|
||||
<code>provider.hass.states</code>.
|
||||
</p>
|
||||
|
||||
<h2>Areas, calendars and energy</h2>
|
||||
<h2>Areas, devices, calendars and energy</h2>
|
||||
<p>
|
||||
Some cards need data that is not an entity. To set it, put an object in
|
||||
the script tag of the provider instead of the list. The object has the
|
||||
@@ -385,7 +581,15 @@ provider.entities = Object.values(provider.hass.states).map((entity) =>
|
||||
<li>
|
||||
<code>areas</code>: a list of areas, each with an
|
||||
<code>area_id</code> and a <code>name</code>. Without it, each
|
||||
<code>area_id</code> of the entities becomes an area.
|
||||
<code>area_id</code> of the entities and devices becomes an area.
|
||||
</li>
|
||||
<li>
|
||||
<code>devices</code>: a list of devices, each with an
|
||||
<code>id</code> and a <code>name</code>, and optionally a
|
||||
<code>manufacturer</code>, <code>model</code> and
|
||||
<code>area_id</code>. Entities belong to a device with their
|
||||
<code>device_id</code>, and cards and strategies then find the
|
||||
device and its name.
|
||||
</li>
|
||||
<li>
|
||||
<code>calendars</code>: the events of calendar entities, by entity
|
||||
@@ -679,14 +883,92 @@ customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pr
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The ID of the provider to use. Without it, the card uses the
|
||||
provider around it, else the first provider on the page.
|
||||
provider around it, else the first provider on the page. The other
|
||||
components take it too.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3><code><ha-embed-section></code></h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>config</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The section configuration or section strategy, the same as in a
|
||||
dashboard. A JSON or YAML script child sets the first
|
||||
configuration.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3><code><ha-embed-view></code></h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>config</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The view configuration or view strategy, the same as in a
|
||||
dashboard. A JSON or YAML script child sets the first
|
||||
configuration.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dashboard</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
A dashboard configuration or dashboard strategy, to show one of its
|
||||
views instead of <code>config</code>. A script child with a
|
||||
<code>dashboard</code> key, and optionally <code>view</code>, sets
|
||||
it.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>view</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The path or index of the view of <code>dashboard</code> to show.
|
||||
Without it, the first view.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3><code><ha-embed-dashboard></code></h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>config</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The dashboard configuration or dashboard strategy. A JSON or YAML
|
||||
script child sets the first configuration.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>view</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The path or index of the first view to show. Without it, the first
|
||||
view.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
The mocked backend knows the actions of lights, switches, climate,
|
||||
covers, fans, locks, media players, and more. Actions that open dialogs,
|
||||
such as <code>more-info</code>, do nothing.
|
||||
such as <code>more-info</code>, do nothing. Dashboards can't be edited,
|
||||
and their toolbar only shows their tabs.
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20261028.4"
|
||||
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;
|
||||
|
||||
@@ -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)[] = [];
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -78,7 +78,12 @@ import {
|
||||
mergeHistoryResults,
|
||||
subscribeHistory,
|
||||
} from "../../data/history";
|
||||
import { fetchStatistics } from "../../data/recorder";
|
||||
import {
|
||||
fetchStatistics,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { resolveEntityIDs } from "../../data/selector";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
@@ -553,8 +558,11 @@ class HaPanelHistory extends LitElement {
|
||||
this._fetchedEntityIds = statisticIds;
|
||||
const fetchId = ++this._statsFetchId;
|
||||
|
||||
// The previous statistics would otherwise show under the new selection
|
||||
// until the request is done, and with only external statistics nothing
|
||||
// else shows the loading.
|
||||
this._statisticsHistory = undefined;
|
||||
if (statisticIds.length === 0) {
|
||||
this._statisticsHistory = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,18 +571,32 @@ class HaPanelHistory extends LitElement {
|
||||
// graph to start at 7AM, need to fetch the statistic from 6AM.
|
||||
statsStartDate.setHours(statsStartDate.getHours() - 1);
|
||||
|
||||
const externalIds = statisticIds.filter(isExternalStatistic);
|
||||
|
||||
let statistics;
|
||||
let metadata: StatisticsMetaData[];
|
||||
try {
|
||||
statistics = await fetchStatistics(
|
||||
this.hass!.callWS,
|
||||
statsStartDate,
|
||||
this._endDate,
|
||||
statisticIds,
|
||||
"hour",
|
||||
undefined,
|
||||
["mean", "state"]
|
||||
);
|
||||
[statistics, metadata] = await Promise.all([
|
||||
fetchStatistics(
|
||||
this.hass!.callWS,
|
||||
statsStartDate,
|
||||
this._endDate,
|
||||
statisticIds,
|
||||
"hour",
|
||||
undefined,
|
||||
["mean", "state"]
|
||||
),
|
||||
// External statistics take name and unit from the metadata. Without
|
||||
// it they are still shown, under their ID.
|
||||
externalIds.length
|
||||
? getStatisticMetadata(this.hass!.callWS, externalIds).catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
} catch (_err) {
|
||||
// External statistics have no state history that would end the loading
|
||||
if (fetchId === this._statsFetchId) {
|
||||
this._statisticsHistory = { line: [], timeline: [] };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -586,19 +608,23 @@ class HaPanelHistory extends LitElement {
|
||||
this.hass!,
|
||||
statistics,
|
||||
statisticIds,
|
||||
true
|
||||
true,
|
||||
Object.fromEntries(metadata.map((meta) => [meta.statistic_id, meta]))
|
||||
);
|
||||
}
|
||||
|
||||
private async _getHistory() {
|
||||
const entityIds = this._getEntityIds();
|
||||
this._fetchedEntityIds = entityIds;
|
||||
const sourceIds = this._getEntityIds();
|
||||
this._fetchedEntityIds = sourceIds;
|
||||
const entityIds = sourceIds.filter((id) => !isExternalStatistic(id));
|
||||
|
||||
if (entityIds.length === 0) {
|
||||
// The running subscription would keep pushing the previous entities.
|
||||
this._unsubscribeHistory();
|
||||
this._stateHistory = undefined;
|
||||
this._isLoading = false;
|
||||
// Charts of external statistics stay mounted, so reset their zoom too
|
||||
this._resetZoom();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import "../../../components/ha-svg-icon";
|
||||
import {
|
||||
AREA_CONTROLS_BUTTONS,
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
MAX_DEFAULT_AREA_CONTROLS,
|
||||
} from "../../../data/area/area_controls";
|
||||
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
@@ -236,6 +237,8 @@ class HuiAreaControlsCardFeature
|
||||
.map((n) => n.value)
|
||||
);
|
||||
|
||||
private _areaEntityIds = memoizeOne(getAreaEntityIds);
|
||||
|
||||
// States are part of the key: the filter reads device classes from them,
|
||||
// so an entity can only match once its state exists.
|
||||
private _controlEntities = memoizeOne(
|
||||
@@ -245,17 +248,23 @@ class HuiAreaControlsCardFeature
|
||||
excludeEntities: string[] | undefined,
|
||||
registries: HomeAssistantRegistries,
|
||||
states: HassEntities
|
||||
) =>
|
||||
getAreaControlEntities(
|
||||
) => {
|
||||
const areaEntities = this._areaEntityIds(
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
areaId,
|
||||
excludeEntities
|
||||
);
|
||||
return getAreaControlEntities(
|
||||
areaEntities,
|
||||
states,
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors,
|
||||
controls,
|
||||
areaId,
|
||||
excludeEntities
|
||||
)
|
||||
controls
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
@@ -13,6 +14,11 @@ import { toggleEntity } from "./entity/toggle-entity";
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"ll-custom": ActionConfig;
|
||||
"hass-navigate": { path: string; replace?: boolean };
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"hass-navigate": HASSDomEvent<HASSDomEvents["hass-navigate"]>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +126,23 @@ export const handleAction = async (
|
||||
}
|
||||
case "navigate":
|
||||
if (actionConfig.navigation_path) {
|
||||
navigate(actionConfig.navigation_path, {
|
||||
replace: actionConfig.navigation_replace,
|
||||
});
|
||||
// A page that embeds the dashboard, such as the demo embed, can
|
||||
// navigate within it instead, and cancel the event
|
||||
const navigateEvent = fireEvent(
|
||||
node,
|
||||
"hass-navigate",
|
||||
{
|
||||
path: actionConfig.navigation_path,
|
||||
replace: actionConfig.navigation_replace,
|
||||
},
|
||||
{ cancelable: true }
|
||||
);
|
||||
|
||||
if (!navigateEvent.defaultPrevented) {
|
||||
navigate(actionConfig.navigation_path, {
|
||||
replace: actionConfig.navigation_replace,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showToast(node, {
|
||||
message: hass.localize(
|
||||
|
||||
@@ -16,6 +16,7 @@ import "../../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
MAX_DEFAULT_AREA_CONTROLS,
|
||||
} from "../../../../data/area/area_controls";
|
||||
import {
|
||||
@@ -64,15 +65,20 @@ export class HuiAreaControlsCardFeatureEditor
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
) => {
|
||||
const areaEntities = getAreaEntityIds(
|
||||
entities,
|
||||
devices,
|
||||
areaId,
|
||||
excludeEntities
|
||||
);
|
||||
const controlEntities = getAreaControlEntities(
|
||||
areaEntities,
|
||||
this.hass!.states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
AREA_CONTROL_DOMAINS,
|
||||
areaId,
|
||||
excludeEntities
|
||||
AREA_CONTROL_DOMAINS
|
||||
);
|
||||
return (
|
||||
Object.keys(controlEntities) as (keyof typeof controlEntities)[]
|
||||
|
||||
+105
-22
@@ -153,6 +153,13 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public backPath?: string;
|
||||
|
||||
/**
|
||||
* Inside another page, such as the demo embed. Views change through
|
||||
* `view-navigate` instead of the page's URL, the page's scroll position and
|
||||
* history stay untouched, and only the tabs and titles show in the toolbar.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) public embedded = false;
|
||||
|
||||
@state() private _curView?: number | "hass-unused-entities";
|
||||
|
||||
@state() private _resourceMode: "yaml" | "storage" = "storage";
|
||||
@@ -561,6 +568,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
class=${classMap({
|
||||
"edit-mode": this._editMode,
|
||||
narrow: this.narrow,
|
||||
embedded: this.embedded,
|
||||
})}
|
||||
>
|
||||
<div class="header">
|
||||
@@ -590,19 +598,23 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
${
|
||||
isSubview || this.backButton
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="navigationIcon"
|
||||
.href=${this._backPath}
|
||||
@click=${this._handleBackClick}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-menu-button
|
||||
slot="navigationIcon"
|
||||
></ha-menu-button>
|
||||
`
|
||||
this.embedded && !isSubview
|
||||
? nothing
|
||||
: isSubview || this.backButton
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="navigationIcon"
|
||||
.href=${
|
||||
this.embedded ? undefined : this._backPath
|
||||
}
|
||||
@click=${this._handleBackClick}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-menu-button
|
||||
slot="navigationIcon"
|
||||
></ha-menu-button>
|
||||
`
|
||||
}
|
||||
${
|
||||
isSubview
|
||||
@@ -619,9 +631,13 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<div class="action-items">
|
||||
${this._renderActionItems()}
|
||||
</div>
|
||||
${
|
||||
this.embedded
|
||||
? nothing
|
||||
: html`<div class="action-items">
|
||||
${this._renderActionItems()}
|
||||
</div>`
|
||||
}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -686,17 +702,30 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
// The page's URL and scroll position belong to the page around it, and
|
||||
// the action items that need the resource mode aren't shown
|
||||
if (this.embedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLovelaceInfo(this.hass).then((info) => {
|
||||
this._resourceMode = info.resource_mode;
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", this._handleWindowScroll, {
|
||||
passive: true,
|
||||
});
|
||||
this._handleUrlChanged();
|
||||
fetchLovelaceInfo(this.hass).then((info) => {
|
||||
this._resourceMode = info.resource_mode;
|
||||
});
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.embedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", this._handleWindowScroll, {
|
||||
passive: true,
|
||||
});
|
||||
@@ -708,6 +737,11 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
if (this.embedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("scroll", this._handleWindowScroll);
|
||||
window.removeEventListener("popstate", this._handlePopState);
|
||||
window.removeEventListener("location-changed", this._locationChanged);
|
||||
@@ -816,7 +850,9 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
if (huiView) {
|
||||
// A view the new configuration no longer has keeps its old one until
|
||||
// another view is selected, instead of failing to find its config
|
||||
if (huiView && !(huiView.index >= this.config.views.length)) {
|
||||
huiView.lovelace = this.lovelace!;
|
||||
}
|
||||
}
|
||||
@@ -824,7 +860,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
if (newSelectView !== undefined) {
|
||||
// Will allow for ripples to start rendering
|
||||
afterNextRender(() => {
|
||||
if (changedProperties.has("route")) {
|
||||
if (changedProperties.has("route") && !this.embedded) {
|
||||
const position =
|
||||
(this._restoreScroll && this._viewScrollPositions[newSelectView]) ||
|
||||
0;
|
||||
@@ -891,6 +927,23 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleBackClick(ev: MouseEvent): void {
|
||||
if (this.embedded) {
|
||||
// The view the subview goes back to, without the page's history
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
|
||||
const curViewConfig =
|
||||
this._curView === "hass-unused-entities" || this._curView === undefined
|
||||
? undefined
|
||||
: views[this._curView];
|
||||
|
||||
fireEvent(this, "view-navigate", {
|
||||
path: curViewConfig?.back_path ?? "",
|
||||
back: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleBackClick(ev, this._backPath, () => this._goBack());
|
||||
}
|
||||
|
||||
@@ -1123,6 +1176,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _navigateToView(path: string | number, replace?: boolean) {
|
||||
if (this.embedded) {
|
||||
fireEvent(this, "view-navigate", { path: String(path) });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this._viewUrl(path);
|
||||
|
||||
const currentUrl = `${location.pathname}${location.search}`;
|
||||
@@ -1140,6 +1199,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
private _handleViewTabNewTabClick(ev: MouseEvent): void {
|
||||
if (
|
||||
this._editMode ||
|
||||
this.embedded ||
|
||||
(ev.button !== 1 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey)
|
||||
) {
|
||||
return;
|
||||
@@ -1232,7 +1292,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
// Save scroll position of current view
|
||||
if (this._curView != null) {
|
||||
if (this._curView != null && !this.embedded) {
|
||||
this._viewScrollPositions[this._curView] = window.scrollY;
|
||||
}
|
||||
|
||||
@@ -1555,6 +1615,19 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
var(--view-container-padding-bottom, 0px)
|
||||
);
|
||||
}
|
||||
/* At least as high as the element it is embedded in, rather than the
|
||||
window, so that panel views fill it */
|
||||
:host([embedded]),
|
||||
.embedded {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* The view background sits below the view, above the opaque
|
||||
background of the element it is embedded in */
|
||||
:host([embedded]) hui-view-container {
|
||||
min-height: 100%;
|
||||
isolation: isolate;
|
||||
}
|
||||
.narrow hui-view-container {
|
||||
--view-container-inset-left: var(--safe-area-inset-left);
|
||||
padding-left: var(--view-container-inset-left);
|
||||
@@ -1594,4 +1667,14 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-root": HUIRoot;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
// A view to show, fired instead of navigating when embedded. `back` is
|
||||
// set for the back button of a subview.
|
||||
"view-navigate": { path: string; back?: boolean };
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"view-navigate": HASSDomEvent<HASSDomEvents["view-navigate"]>;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
|
||||
import { getAreaControlEntities } from "../../../../data/area/area_controls";
|
||||
import {
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
} from "../../../../data/area/area_controls";
|
||||
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -90,15 +93,20 @@ export class AreasOverviewViewStrategy extends ReactiveElement {
|
||||
const controls: AreaControlDomain[] = AREA_CONTROL_DOMAINS.filter(
|
||||
(a) => a !== "switch" // Exclude switches control for areas as we don't know what the switches control
|
||||
);
|
||||
const areaEntities = getAreaEntityIds(
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
area.area_id,
|
||||
hiddenEntities
|
||||
);
|
||||
const controlEntities = getAreaControlEntities(
|
||||
areaEntities,
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
controls,
|
||||
area.area_id,
|
||||
hiddenEntities
|
||||
controls
|
||||
);
|
||||
|
||||
const filteredControls = controls.filter(
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface Lovelace {
|
||||
saveConfig: (newConfig: LovelaceRawConfig) => Promise<void>;
|
||||
deleteConfig: () => Promise<void>;
|
||||
showToast: (params: ShowToastParams) => void;
|
||||
/**
|
||||
* Shown inside another page, such as the demo embed, which owns the
|
||||
* window's history and scroll position.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export interface LovelaceBadge extends HTMLElement {
|
||||
|
||||
@@ -79,6 +79,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
@state() private _sidebarVisible = true;
|
||||
|
||||
// Set on connecting, so that the next update reads the sidebar tab from the
|
||||
// page's history state, once `lovelace` has been set
|
||||
private _readSidebarTab = false;
|
||||
|
||||
private _contentScrollTop = 0;
|
||||
|
||||
private _sidebarScrollTop = 0;
|
||||
@@ -142,7 +146,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
"section-visibility-changed",
|
||||
this._sectionVisibilityChanged
|
||||
);
|
||||
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
|
||||
this._readSidebarTab = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -154,6 +159,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
// An embedded dashboard leaves the page's history state alone
|
||||
if (this._readSidebarTab) {
|
||||
this._readSidebarTab = false;
|
||||
this._sidebarTabActive =
|
||||
!this.lovelace?.embedded && Boolean(getHistoryState()?.sidebar);
|
||||
}
|
||||
if (changedProperties.has("sections")) {
|
||||
this._computeSectionsCount();
|
||||
}
|
||||
@@ -491,6 +502,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
private _toggleView() {
|
||||
// The page around an embedded dashboard owns the window's history and
|
||||
// scroll position
|
||||
if (this.lovelace?.embedded) {
|
||||
this._sidebarTabActive = !this._sidebarTabActive;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current scroll position
|
||||
if (this._sidebarTabActive) {
|
||||
this._sidebarScrollTop = window.scrollY;
|
||||
|
||||
@@ -968,7 +968,7 @@ type: module</pre>`,
|
||||
? {
|
||||
icon: mdiAlertCircleOutline,
|
||||
text: localize(
|
||||
"ui.panel.marketplace.repository.community.open_issues",
|
||||
"ui.panel.marketplace.repository.community.open_issues_and_prs",
|
||||
{
|
||||
number: repository.issues,
|
||||
count: html`<strong
|
||||
@@ -980,7 +980,7 @@ type: module</pre>`,
|
||||
: {
|
||||
icon: mdiCheckCircleOutline,
|
||||
text: localize(
|
||||
"ui.panel.marketplace.repository.community.no_open_issues"
|
||||
"ui.panel.marketplace.repository.community.no_open_issues_and_prs"
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -13118,8 +13118,8 @@
|
||||
"last_change": "Last change on GitHub {time}",
|
||||
"downloads": "{number, plural,\n one {Downloaded {count} time}\n other {Downloaded {count} times}\n}",
|
||||
"stars": "{number, plural,\n one {{count} star on GitHub}\n other {{count} stars on GitHub}\n}",
|
||||
"open_issues": "{number, plural,\n one {{count} open issue on GitHub}\n other {{count} open issues on GitHub}\n}",
|
||||
"no_open_issues": "No open issues on GitHub"
|
||||
"open_issues_and_prs": "{number, plural,\n one {{count} open issue or pull request on GitHub}\n other {{count} open issues and pull requests on GitHub}\n}",
|
||||
"no_open_issues_and_prs": "No open issues or pull requests on GitHub"
|
||||
},
|
||||
"details": {
|
||||
"requires": "Requires Home Assistant {version} or newer",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { mdiChartLine } from "@mdi/js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "../../../src/components/target-picker/ha-target-picker-item-row";
|
||||
import type { HaTargetPickerItemRow } from "../../../src/components/target-picker/ha-target-picker-item-row";
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
|
||||
import { StatisticMeanType } from "../../../src/data/recorder";
|
||||
import type {
|
||||
ExtractFromTargetResult,
|
||||
TargetType,
|
||||
@@ -226,3 +228,77 @@ describe("ha-target-picker-item-row target extraction", () => {
|
||||
expect(result.referenced_entities).toEqual(["light.missing"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-target-picker-item-row external statistics", () => {
|
||||
const statisticId = "example:outdoor_temperature";
|
||||
|
||||
// Resolves an entity row against the statistics metadata in `metadata`.
|
||||
const entityRow = async (itemId: string, metadata: unknown[]) => {
|
||||
const el = document.createElement(
|
||||
"ha-target-picker-item-row"
|
||||
) as HaTargetPickerItemRow;
|
||||
el.type = "entity";
|
||||
el.itemId = itemId;
|
||||
const row = el as any;
|
||||
row._registries = {
|
||||
areas: {},
|
||||
devices: {},
|
||||
entities: {},
|
||||
floors: {},
|
||||
};
|
||||
row._states = {};
|
||||
row._i18n = {
|
||||
localize: (key: string) =>
|
||||
key === "component.example.title" ? "Example" : "",
|
||||
};
|
||||
const messages: Record<string, unknown>[] = [];
|
||||
row._api = {
|
||||
callWS: async (message: Record<string, unknown>) => {
|
||||
messages.push(message);
|
||||
return metadata;
|
||||
},
|
||||
};
|
||||
|
||||
await row._updateItemData();
|
||||
return { data: row._itemData("entity", itemId), messages };
|
||||
};
|
||||
|
||||
it("shows an external statistic by the name in its metadata", async () => {
|
||||
const { data, messages } = await entityRow(statisticId, [
|
||||
{
|
||||
statistic_id: statisticId,
|
||||
source: "example",
|
||||
name: "Outdoor temperature",
|
||||
statistics_unit_of_measurement: "°C",
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "recorder/get_statistics_metadata",
|
||||
statistic_ids: [statisticId],
|
||||
},
|
||||
]);
|
||||
expect(data.name).toBe("Outdoor temperature");
|
||||
expect(data.context).toBe("Example");
|
||||
expect(data.fallbackIconPath).toBe(mdiChartLine);
|
||||
expect(data.notFound).toBe(false);
|
||||
});
|
||||
|
||||
it("shows an external statistic without metadata as not found", async () => {
|
||||
const { data } = await entityRow(statisticId, []);
|
||||
|
||||
expect(data.name).toBe(statisticId);
|
||||
expect(data.notFound).toBe(true);
|
||||
});
|
||||
|
||||
it("does not look up statistics for an entity ID", async () => {
|
||||
const { data, messages } = await entityRow("sensor.missing", []);
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(data.notFound).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isTimelineEntity,
|
||||
mergeHistoryResults,
|
||||
} from "../../src/data/history";
|
||||
import { StatisticMeanType } from "../../src/data/recorder";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { mockDevice, mockEntity } from "../common/entity/context/context-mock";
|
||||
import {
|
||||
@@ -363,3 +364,97 @@ describe("mergeHistoryResults", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertStatisticsToHistory without an entity", () => {
|
||||
const statisticId = "example:outdoor_temperature";
|
||||
const statistics = {
|
||||
[statisticId]: [
|
||||
{ start: 1_700_000_000_000, end: 1_700_003_600_000, mean: 12.5 },
|
||||
{ start: 1_700_003_600_000, end: 1_700_007_200_000, mean: 13.5 },
|
||||
],
|
||||
};
|
||||
|
||||
it("takes name and unit of an external statistic from its metadata", () => {
|
||||
const result = convertStatisticsToHistory(
|
||||
createMockHass(),
|
||||
statistics,
|
||||
[statisticId],
|
||||
true,
|
||||
{
|
||||
[statisticId]: {
|
||||
statistic_id: statisticId,
|
||||
source: "example",
|
||||
name: "Outdoor temperature",
|
||||
statistics_unit_of_measurement: "°C",
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.line).toHaveLength(1);
|
||||
expect(result.line[0].unit).toBe("°C");
|
||||
expect(result.line[0].data.map(({ name }) => name)).toEqual([
|
||||
"Outdoor temperature",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a removed entity in one chart with its state history", () => {
|
||||
// The entity is gone from hass.states, but its recent states are recorded
|
||||
const entityId = "sensor.removed_temperature";
|
||||
const temperature = {
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
};
|
||||
const historyResult = computeHistory(
|
||||
createMockHass(),
|
||||
{ [entityId]: [{ s: "21", a: temperature, lu: 1_700_010_000 }] },
|
||||
[],
|
||||
mockLocalize,
|
||||
true
|
||||
);
|
||||
const ltsResult = convertStatisticsToHistory(
|
||||
createMockHass(),
|
||||
{
|
||||
[entityId]: [
|
||||
{ start: 1_700_000_000_000, end: 1_700_003_600_000, mean: 20 },
|
||||
],
|
||||
},
|
||||
[entityId],
|
||||
true,
|
||||
{
|
||||
[entityId]: {
|
||||
statistic_id: entityId,
|
||||
source: "recorder",
|
||||
name: null,
|
||||
statistics_unit_of_measurement: "°C",
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const merged = mergeHistoryResults(historyResult, ltsResult, true);
|
||||
|
||||
expect(merged.line).toHaveLength(1);
|
||||
expect(merged.line[0].unit).toBe("°C");
|
||||
expect(merged.line[0].data.map(({ entity_id }) => entity_id)).toEqual([
|
||||
entityId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the ID and no unit without metadata", () => {
|
||||
const result = convertStatisticsToHistory(
|
||||
createMockHass(),
|
||||
statistics,
|
||||
[statisticId],
|
||||
true
|
||||
);
|
||||
|
||||
expect(result.line).toHaveLength(1);
|
||||
expect(result.line[0].unit).toBe(" ");
|
||||
expect(result.line[0].data[0].name).not.toBe("Outdoor temperature");
|
||||
});
|
||||
});
|
||||
@@ -673,7 +673,7 @@ describe("ha-marketplace-repository-dashboard", () => {
|
||||
)
|
||||
).toEqual([
|
||||
"ui.panel.marketplace.repository.community.stars",
|
||||
"ui.panel.marketplace.repository.community.open_issues",
|
||||
"ui.panel.marketplace.repository.community.open_issues_and_prs",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ describe("Marketplace strings", () => {
|
||||
"1 star on GitHub",
|
||||
],
|
||||
[
|
||||
"repository.community.open_issues",
|
||||
"repository.community.open_issues_and_prs",
|
||||
{ number: 1, count: "1" },
|
||||
"1 open issue on GitHub",
|
||||
"1 open issue or pull request on GitHub",
|
||||
],
|
||||
])("formats %s with %o", (key, values, expected) => {
|
||||
expect(format(key, values)).toBe(expected);
|
||||
|
||||
Reference in new issue
Block a user