Compare commits

..
14 Commits
Author SHA1 Message Date
Bram Kragten e32bc0696e Bumped version to 20261028.5 2026-11-06 15:14:00 +01:00
WendelinandClaude Opus 5.5 6327304f4d Fix bright card outlines on Shelly Wall Display (#55704)
* Keep rgba() colors in the legacy CSS build

Raising the legacy floor to Chrome 67 lets lightningcss rewrite rgba()
to 8-digit hex (#rrggbbaa), since Chrome 62 supports it. Kiosk webviews
like the Shelly Wall Display get the legacy build but can't parse that,
so var(--divider-color) borders on cards, toggles and buttons fell back
to currentColor and showed as bright outlines, the same symptom as
#52986. Always compile hex alpha colors to rgba() in legacy builds, for
both css`` templates and imported stylesheets. Modern builds are
unchanged.

Also drop the loader's redundant /* global */ header, which ESLint now
rejects because module and require are already built-in globals in
.cjs files.

Fixes #55699

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGHbhPo6Q2xNuKcJen

* Also keep rgba() colors in the legacy service worker build

The template loader runs with the legacy-sw browserslist env for the
service worker layer, which an exact match on legacy missed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGHbhPo6Q2xNuKcJen

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 15:12:17 +01:00
Petar Petrov 11c79e09e9 Fit narrow chart legends to three rows instead of two (#55697) 2026-11-06 15:12:16 +01:00
Paul Bottein 5060b37381 Fix truncated buttons in the voice assistant setup (#55677) 2026-11-06 15:12:14 +01:00
Aidan Timson 63e3a04f15 Fix the max-age of the demo and cast cache headers (#55676) 2026-11-06 15:12:13 +01:00
Aidan Timson 818de1da8e Render full dashboards with their tabs in the demo embed (#55640)
* Add an embedded mode to hui-root that leaves the page's URL alone

* Add a dashboard component to the demo embed

* Keep embedded dashboards' back navigation, height and sidebar tabs inside the embed

* Follow anchors, replacements and new configs in embedded dashboards

* Go back past a followed back_path in embedded dashboards

* Isolate embedded view containers so view backgrounds show

* Match decoded and aliased views and skip empty embedded dashboards

* Read the sidebar tab once sections views have their dashboard

* Declare the navigate events in HTMLElementEventMap

* Skip the Lovelace info fetch and its mock in embedded dashboards
2026-11-06 15:12:11 +01:00
Aidan Timson 70897feab1 Render views and dashboard strategies in the demo embed (#55639)
* Add a view component to the demo embed

* Show only the latest generated dashboard in the demo embed

* Stop stale and repeated strategy results in embedded views

* Draw view themes and backgrounds in embedded views
2026-11-06 15:12:10 +01:00
Aidan Timson f827c09e72 Let embed entities set their entity registry options (#55675) 2026-11-06 15:09:48 +01:00
Aidan Timson a4bb97b71c Render sections in the demo embed, with a device registry (#55638)
* Share embed provider lookup and theme in a base element

* Add devices to the demo embed provider

* Add a section component to the demo embed

* Draw section backgrounds and accept more section configs in the demo embed
2026-11-06 15:09:43 +01:00
puddly 509561aabe Allow the ESPHome serial proxy URL to be null (#55665)
* Allow the ESPHome serial proxy URL to be None

* Clarify comment
2026-11-06 14:58:14 +01:00
Christian Wiedel 0446eb5a62 Fix wrapped flow dialog titles overlapping the subtitle (#55661) 2026-11-06 14:58:13 +01:00
Paul Bottein e815c44a42 Fix area controls freezing large dashboards (#55650) 2026-11-06 14:58:11 +01:00
Joost LekkerkerkerandClaude Opus 5.5 4551a87a7a Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

The GitHub open issues count includes pull requests. Rename the
translation keys and update the English strings so the label matches
the number shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Update marketplace tests for the renamed open issues keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-06 14:58:10 +01:00
Alexander Hass 23e376c7b4 Resolve external statistics in the History panel (#55521)
* Resolve external statistics in the History panel

The History panel plots external statistics (`<domain>:<object_id>`), for example when the statistics graph card links to it, but treated them as entities everywhere else.

- The sources picker looks up an external statistic in the statistics metadata and shows its name instead of "Entity not found".
- Statistics without an entity take name and unit from their metadata, so the legend and tooltip no longer show the prettified ID without a unit.
- External statistic IDs are no longer sent to `history/stream`. Core rejects the whole subscription for an ID that is not an entity ID, which dropped the state history of every other picked entity.

* Use statistic metadata only for external statistics

The statistics of a removed entity got the unit from their metadata, but no device class. Its recent state history has the same unit and a device class, so both ended up in separate charts and the overlap was no longer trimmed. Other statistics keep a blank unit again and merge into the history line of their entity. The metadata is only fetched for external statistics.

* Reset the zoom when only external statistics remain

Removing the last entity from a zoomed view returned before the zoom was reset. The chart was rebuilt at full range, but the zoom slider kept the previous range.

* Reset the chart zoom when only external statistics are shown

Charts of external statistics stay mounted when the date range changes, because there is no state history to load. Resetting only the zoom range left the chart zoomed while the reset button was disabled. The zoom is now reset on the charts as well, and the normal path is unchanged again.

* Keep the statistics when the metadata lookup fails

A failed metadata lookup rejected the whole request, so the fetched statistics were dropped and the panel could stay on the loading spinner. External statistics are now shown under their ID instead.

* End the loading when the statistics request fails

With only external statistics selected there is no state history, so a failed statistics request left the panel on the loading spinner. A failed request now sets an empty result, guarded by the fetch ID so that an older request cannot overwrite newer data.

* Show external statistics like the statistic picker and inline the stream filter

The sources picker row of an external statistic gets the chart line icon and the source integration as context, as in ha-statistic-picker, instead of the default entity icon. The filter for the history stream has a single caller, so it is inlined in the History panel and its export and test are dropped. The comment on the blank unit of other statistics moves to the isExternalStatistic check it describes.

* Clear the previous statistics before fetching new ones in the History panel

Switching from one external statistic to another kept the chart of the previous one on screen and in the download until the new request was done, as no state history shows the loading then. The statistics are now cleared before the request, so the panel shows its loading state instead.
2026-11-06 14:58:09 +01:00
42 changed files with 1799 additions and 286 deletions

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,
},
})
)
+3
View File
@@ -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 },
},
},
],
+5 -5
View File
@@ -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
+5 -5
View File
@@ -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
+173
View File
@@ -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>`;
}
}
+3
View File
@@ -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
View File
@@ -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;
}
`,
];
}
+173
View File
@@ -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;
}
}
+40 -12
View File
@@ -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 {
+95
View File
@@ -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;
}
}
+134
View File
@@ -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;
}
}
+217
View File
@@ -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);
}
`,
];
}
+14
View File
@@ -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;
};
+35 -1
View File
@@ -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;
}
`,
];
+291 -9
View File
@@ -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>&lt;ha-embed-provider&gt;</code> holds the mocked backend and its
entities. These components render dashboard content with the backend of
a provider:
</p>
<ul>
<li><code>&lt;ha-embed-card&gt;</code> renders one card.</li>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
<code>&lt;ha-embed-section&gt;</code> renders one section, or a
section strategy.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
<code>&lt;ha-embed-view&gt;</code> renders one view, a view strategy,
or one view of a dashboard.
</li>
<li>
<code>&lt;ha-embed-dashboard&gt;</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>&lt;ha-embed-section&gt;</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>&lt;ha-embed-section&gt;
&lt;script type="text/yaml"&gt;
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen
- type: tile
entity: switch.kettle
&lt;/script&gt;
&lt;/ha-embed-section&gt;</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>&lt;ha-embed-view&gt;</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>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
type: sections
badges:
- entity: sensor.outside_temperature
sections:
- cards: …
&lt;/script&gt;
&lt;/ha-embed-view&gt;</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>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
dashboard:
strategy:
type: custom:my-dashboard
view: overview
&lt;/script&gt;
&lt;/ha-embed-view&gt;</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>&lt;ha-embed-dashboard&gt;</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>&lt;ha-embed-dashboard style="height: 480px"&gt;
&lt;script type="text/yaml"&gt;
views:
- title: Home
path: home
cards: …
- title: Climate
path: climate
cards: …
&lt;/script&gt;
&lt;/ha-embed-dashboard&gt;</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) =&gt;
<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) =&gt;
<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>&lt;ha-embed-section&gt;</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>&lt;ha-embed-view&gt;</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>&lt;ha-embed-dashboard&gt;</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>
+54
View File
@@ -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 -19
View File
@@ -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
View File
@@ -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;
+9 -3
View File
@@ -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(
{
+16 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+6
View File
@@ -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;
};
+40 -14
View File
@@ -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() {
+23 -3
View File
@@ -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
View File
@@ -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(
+5
View File
@@ -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 {
+20 -1
View File
@@ -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"
),
}
);
+2 -2
View File
@@ -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);
});
});
+95
View File
@@ -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",
]);
});
+2 -2
View File
@@ -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);