mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-05 20:44:32 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7792e1fe |
No files matched your search
@@ -18,7 +18,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Development
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Development
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
|
||||
import type { Auth, Connection } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../../src/cast/cast_manager";
|
||||
import {
|
||||
@@ -13,14 +13,12 @@ import {
|
||||
enableWrite,
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
import "../../../../src/components/ha-list-item";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import {
|
||||
getLegacyLovelaceCollection,
|
||||
getLovelaceCollection,
|
||||
@@ -54,9 +52,6 @@ class HcCast extends LitElement {
|
||||
`
|
||||
: undefined;
|
||||
|
||||
const views = this.lovelaceViews ?? [{ title: "Home" }];
|
||||
const hasIcons = views.some((view) => view.icon);
|
||||
|
||||
return html`
|
||||
<hc-layout .auth=${this.auth} .connection=${this.connection}>
|
||||
${
|
||||
@@ -97,41 +92,45 @@ class HcCast extends LitElement {
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<div class="section-header" id="pick-view-header">
|
||||
PICK A VIEW
|
||||
</div>
|
||||
<ha-list-selectable
|
||||
controlled
|
||||
aria-labelledby="pick-view-header"
|
||||
>
|
||||
${views.map(
|
||||
<div class="section-header">PICK A VIEW</div>
|
||||
<ha-list @action=${this._handlePickView} activatable>
|
||||
${(
|
||||
this.lovelaceViews ?? [
|
||||
{
|
||||
title: "Home",
|
||||
},
|
||||
]
|
||||
).map(
|
||||
(view, idx) => html`
|
||||
<ha-list-item-option
|
||||
data-index=${idx}
|
||||
@click=${this._handlePickView}
|
||||
.headline=${view.title || view.path || "Unnamed view"}
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.activated=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
.selected=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
>
|
||||
${view.title || view.path || "Unnamed view"}
|
||||
${
|
||||
!hasIcons
|
||||
? nothing
|
||||
: view.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
view.icon
|
||||
? html`
|
||||
<ha-icon
|
||||
.icon=${view.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
slot="graphic"
|
||||
></ha-icon>
|
||||
`
|
||||
: html`<ha-svg-icon
|
||||
slot="item-icon"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</ha-list-item-option>
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
)}</ha-list
|
||||
>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -194,6 +193,14 @@ class HcCast extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
}
|
||||
|
||||
private async _handleSkipSaveTokens() {
|
||||
this.askWrite = false;
|
||||
}
|
||||
@@ -207,13 +214,8 @@ class HcCast extends LitElement {
|
||||
this.castManager.requestSession();
|
||||
}
|
||||
|
||||
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
|
||||
// for the selected view, so the current view can still be cast again.
|
||||
private async _handlePickView(
|
||||
ev: HASSDomCurrentTargetEvent<HaListItemOption>
|
||||
) {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
const path = this.lovelaceViews?.[index]?.path ?? index;
|
||||
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
|
||||
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
|
||||
await ensureConnectedCastSession(this.castManager!, this.auth!);
|
||||
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
|
||||
}
|
||||
@@ -268,11 +270,16 @@ class HcCast extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-list-item-option ha-icon,
|
||||
ha-list-item-option ha-svg-icon {
|
||||
ha-list-item ha-icon,
|
||||
ha-list-item ha-svg-icon {
|
||||
padding: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
:host([hide-icons]) ha-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
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,9 +4,6 @@ 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.
|
||||
|
||||
+141
-22
@@ -1,44 +1,139 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
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 { 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 { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
|
||||
* 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.
|
||||
*
|
||||
* The card takes the columns and rows it would take in a section, so cards
|
||||
* in an element with `display: grid` and 12 columns lay out like a section.
|
||||
* It also sets `--column-size` and `--row-size` on itself.
|
||||
*/
|
||||
@customElement("ha-embed-card")
|
||||
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
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;
|
||||
|
||||
private _card = createRef<HuiCard>();
|
||||
|
||||
// A card names its type
|
||||
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
|
||||
return "type" in data && typeof data.type === "string";
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
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>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
@@ -68,7 +163,7 @@ export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
embedThemeStyles,
|
||||
css`
|
||||
/* The sizes of a card in a section, see hui-grid-section */
|
||||
:host {
|
||||
@@ -81,6 +176,9 @@ export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
: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(
|
||||
@@ -88,6 +186,27 @@ export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
)
|
||||
);
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
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,7 +8,6 @@ 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";
|
||||
@@ -23,8 +22,6 @@ 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";
|
||||
@@ -43,7 +40,6 @@ import { readScriptData } from "./script-data";
|
||||
interface EmbedProviderData {
|
||||
entities?: EntityInput[];
|
||||
areas?: DemoArea[];
|
||||
devices?: DemoDevice[];
|
||||
calendars?: Record<string, CalendarEventApiData[]>;
|
||||
energy?: Partial<EnergyPreferences>;
|
||||
}
|
||||
@@ -58,8 +54,8 @@ const areaName = (areaId: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a mocked Home Assistant backend for all embed elements inside it.
|
||||
* Elements in the same provider share their entities.
|
||||
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
|
||||
* inside it. Cards in the same provider share their entities.
|
||||
*/
|
||||
@customElement("ha-embed-provider")
|
||||
export class HaEmbedProvider extends LitElement {
|
||||
@@ -71,19 +67,13 @@ 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`, `devices`, `calendars` and `energy`.
|
||||
* `areas`, `calendars` and `energy`.
|
||||
*/
|
||||
@property({ type: Array }) public entities: EntityInput[] = [];
|
||||
|
||||
/**
|
||||
* All areas. Without them, an area for each `area_id` of the entities and
|
||||
* devices.
|
||||
*/
|
||||
/** All areas. Without them, an area for each `area_id` of the entities. */
|
||||
@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.
|
||||
@@ -165,11 +155,7 @@ export class HaEmbedProvider extends LitElement {
|
||||
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.areas)
|
||||
);
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
// The registry of the entities, without settings such as calendar colors
|
||||
const registryEntries = (currentHass: MockHomeAssistant) =>
|
||||
Object.values(currentHass.entities).map(
|
||||
(entity): EntityRegistryEntry => ({
|
||||
@@ -180,9 +166,6 @@ 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) =>
|
||||
@@ -239,7 +222,6 @@ 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;
|
||||
}
|
||||
@@ -256,19 +238,14 @@ export class HaEmbedProvider extends LitElement {
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has("entities") ||
|
||||
changedProps.has("areas") ||
|
||||
changedProps.has("devices")
|
||||
) {
|
||||
const devices = this.devices ?? [];
|
||||
if (changedProps.has("entities") || changedProps.has("areas")) {
|
||||
const areas: DemoArea[] =
|
||||
this.areas ??
|
||||
[
|
||||
...new Set(
|
||||
[...this.entities, ...devices].flatMap((item) =>
|
||||
item.area_id === undefined ? [] : [item.area_id]
|
||||
)
|
||||
this.entities
|
||||
.map((entity) => entity.area_id)
|
||||
.filter((areaId) => areaId !== undefined)
|
||||
),
|
||||
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
|
||||
|
||||
@@ -276,16 +253,11 @@ export class HaEmbedProvider extends LitElement {
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = toAreaRegistryEntry(area);
|
||||
});
|
||||
const devicesById: Record<string, DeviceRegistryEntry> = {};
|
||||
devices.forEach((device) => {
|
||||
devicesById[device.id] = toDeviceRegistryEntry(device);
|
||||
});
|
||||
// Before the entities, so that their names can include the area and
|
||||
// device
|
||||
this.hass.updateHass({ areas: areasById, devices: devicesById });
|
||||
// Before the entities, so that their names can include the area
|
||||
this.hass.updateHass({ areas: areasById });
|
||||
|
||||
// Entities that services changed keep their state when only the areas
|
||||
// or devices change
|
||||
// change
|
||||
if (changedProps.has("entities")) {
|
||||
this.hass.addEntities(this.entities, true);
|
||||
} else {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
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,17 +15,3 @@ 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 { css, unsafeCSS } from "lit";
|
||||
import { 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,37 +59,3 @@ 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,24 +108,15 @@
|
||||
against a mocked backend in the browser. No Home Assistant server is
|
||||
necessary.
|
||||
</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>
|
||||
<p>There are two components:</p>
|
||||
<ul>
|
||||
<li><code><ha-embed-card></code> renders one card.</li>
|
||||
<li>
|
||||
<code><ha-embed-section></code> renders one section, or a
|
||||
section strategy.
|
||||
<code><ha-embed-provider></code> holds the mocked backend and
|
||||
its entities.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
<code><ha-embed-card></code> renders one card. It uses the
|
||||
backend of a provider.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -333,193 +324,6 @@
|
||||
</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>
|
||||
@@ -571,7 +375,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =>
|
||||
<code>provider.hass.states</code>.
|
||||
</p>
|
||||
|
||||
<h2>Areas, devices, calendars and energy</h2>
|
||||
<h2>Areas, 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
|
||||
@@ -581,15 +385,7 @@ 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 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.
|
||||
<code>area_id</code> of the entities becomes an area.
|
||||
</li>
|
||||
<li>
|
||||
<code>calendars</code>: the events of calendar entities, by entity
|
||||
@@ -883,92 +679,14 @@ 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. 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.
|
||||
provider around it, else the first provider on the page.
|
||||
</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. Dashboards can't be edited,
|
||||
and their toolbar only shows their tabs.
|
||||
such as <code>more-info</code>, do nothing.
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
|
||||
@@ -1,60 +1,6 @@
|
||||
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,6 +1,24 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
import { baseDevice } from "./device_registry";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
|
||||
@@ -81,10 +81,6 @@ export default [
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Charts",
|
||||
pages: ["state-history-charts", "statistics-chart"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
|
||||
@@ -683,13 +683,8 @@ const SCHEMAS: {
|
||||
name: "Unit of measurement",
|
||||
input: {
|
||||
sensor_device_class: {
|
||||
name: "Device class of the unit below",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
context: { filter_unit_of_measurement: "unit_linked" },
|
||||
},
|
||||
},
|
||||
name: "Device class",
|
||||
selector: { device_class: { domain: "sensor" } },
|
||||
},
|
||||
sensor_state_class: {
|
||||
name: "State class",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: History charts
|
||||
subtitle: State history as timelines and line charts.
|
||||
---
|
||||
|
||||
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/state-history-charts";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
HistoryResult,
|
||||
LineChartState,
|
||||
TimelineState,
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
|
||||
const END = new Date();
|
||||
END.setMinutes(0, 0, 0);
|
||||
const START = new Date(END.getTime() - HOURS * HOUR);
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "sensor.outdoor_temperature",
|
||||
state: "12.4",
|
||||
attributes: {
|
||||
friendly_name: "Outdoor temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Living room light" },
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Front door", device_class: "door" },
|
||||
},
|
||||
];
|
||||
|
||||
const temperatureStates = (): LineChartState[] =>
|
||||
Array.from({ length: HOURS * 2 }, (_, index) => {
|
||||
const time = START.getTime() + (index * HOUR) / 2;
|
||||
const hourOfDay = new Date(time).getHours();
|
||||
const value =
|
||||
10 +
|
||||
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
|
||||
0.5 * Math.sin(time / HOUR);
|
||||
return {
|
||||
state: value.toFixed(1),
|
||||
last_changed: time,
|
||||
};
|
||||
});
|
||||
|
||||
const toggleStates = (
|
||||
onHours: [number, number][],
|
||||
onLabel: string,
|
||||
offLabel: string
|
||||
): TimelineState[] => {
|
||||
const on = { state: "on", state_localize: onLabel };
|
||||
const off = { state: "off", state_localize: offLabel };
|
||||
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
|
||||
onHours.forEach(([from, to]) => {
|
||||
states.push(
|
||||
{ ...on, last_changed: START.getTime() + from * HOUR },
|
||||
{ ...off, last_changed: START.getTime() + to * HOUR }
|
||||
);
|
||||
});
|
||||
return states;
|
||||
};
|
||||
|
||||
const HISTORY: HistoryResult = {
|
||||
line: [
|
||||
{
|
||||
unit: "°C",
|
||||
device_class: "temperature",
|
||||
identifier: "temperature",
|
||||
data: [
|
||||
{
|
||||
domain: "sensor",
|
||||
name: "Outdoor temperature",
|
||||
entity_id: "sensor.outdoor_temperature",
|
||||
states: temperatureStates(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
name: "Living room light",
|
||||
entity_id: "light.living_room",
|
||||
data: toggleStates(
|
||||
[
|
||||
[6, 8],
|
||||
[18, 23],
|
||||
[30, 32],
|
||||
[42, 47],
|
||||
],
|
||||
"On",
|
||||
"Off"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Front door",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
data: toggleStates(
|
||||
[
|
||||
[7.5, 7.6],
|
||||
[17, 17.2],
|
||||
[31.5, 31.6],
|
||||
[41, 41.1],
|
||||
],
|
||||
"Open",
|
||||
"Closed"
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const LIGHT_ONLY: HistoryResult = {
|
||||
line: [],
|
||||
timeline: [HISTORY.timeline[0]],
|
||||
};
|
||||
|
||||
interface Example {
|
||||
title: string;
|
||||
description: string;
|
||||
history: HistoryResult;
|
||||
showNames?: boolean;
|
||||
insideLabels?: boolean;
|
||||
syncCharts?: boolean;
|
||||
}
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Names in a column",
|
||||
description:
|
||||
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
|
||||
history: HISTORY,
|
||||
},
|
||||
{
|
||||
title: "Names inside the timeline, synced zoom",
|
||||
description:
|
||||
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
|
||||
history: HISTORY,
|
||||
insideLabels: true,
|
||||
syncCharts: true,
|
||||
},
|
||||
{
|
||||
title: "Single entity without names",
|
||||
description:
|
||||
"Like the more-info dialog, where the entity is already known.",
|
||||
history: LIGHT_ONLY,
|
||||
showNames: false,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-state-history-charts")
|
||||
export class DemoStateHistoryCharts extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<state-history-charts
|
||||
.historyData=${example.history}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.showNames=${example.showNames ?? true}
|
||||
.insideLabels=${example.insideLabels ?? false}
|
||||
.syncCharts=${example.syncCharts ?? false}
|
||||
.clickForMoreInfo=${false}
|
||||
></state-history-charts>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-state-history-charts": DemoStateHistoryCharts;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: Statistics chart
|
||||
subtitle: Long-term statistics as a bar or line chart.
|
||||
---
|
||||
|
||||
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
StatisticsMetaData,
|
||||
StatisticType,
|
||||
StatisticValue,
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
|
||||
const END = new Date();
|
||||
END.setMinutes(0, 0, 0);
|
||||
const START = new Date(END.getTime() - HOURS * HOUR);
|
||||
|
||||
const round = (value: number, digits: number) =>
|
||||
Math.round(value * 10 ** digits) / 10 ** digits;
|
||||
|
||||
const hourlyValues = (
|
||||
compute: (start: number) => Partial<StatisticValue>
|
||||
): StatisticValue[] =>
|
||||
Array.from({ length: HOURS }, (_, hour) => {
|
||||
const start = START.getTime() + hour * HOUR;
|
||||
return { start, end: start + HOUR, ...compute(start) };
|
||||
});
|
||||
|
||||
const energyValues = (dayUse: number, eveningUse: number) => {
|
||||
let sum = 0;
|
||||
return hourlyValues((start) => {
|
||||
const hourOfDay = new Date(start).getHours();
|
||||
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
|
||||
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
|
||||
sum += change;
|
||||
return { change, sum: round(sum, 2) };
|
||||
});
|
||||
};
|
||||
|
||||
const temperatureValues = (average: number, swing: number) =>
|
||||
hourlyValues((start) => {
|
||||
const hourOfDay = new Date(start).getHours();
|
||||
const mean =
|
||||
average +
|
||||
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
|
||||
0.3 * Math.sin(start / HOUR);
|
||||
return {
|
||||
mean: round(mean, 1),
|
||||
min: round(mean - 0.4, 1),
|
||||
max: round(mean + 0.4, 1),
|
||||
};
|
||||
});
|
||||
|
||||
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
|
||||
const LIGHTING_ID = "sensor.lighting_energy";
|
||||
const LIVING_ROOM_ID = "sensor.living_room_temperature";
|
||||
const BEDROOM_ID = "sensor.bedroom_temperature";
|
||||
|
||||
const HEAT_PUMP = energyValues(0.4, 1.2);
|
||||
const LIGHTING = energyValues(0.05, 0.3);
|
||||
const LIVING_ROOM = temperatureValues(21, 1.5);
|
||||
const BEDROOM = temperatureValues(18.5, 1);
|
||||
|
||||
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
|
||||
statistic_id: id,
|
||||
statistics_unit_of_measurement: "kWh",
|
||||
source: "recorder",
|
||||
name,
|
||||
has_sum: true,
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
unit_class: "energy",
|
||||
});
|
||||
|
||||
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
|
||||
statistic_id: id,
|
||||
statistics_unit_of_measurement: "°C",
|
||||
source: "recorder",
|
||||
name,
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
});
|
||||
|
||||
const METADATA: Record<string, StatisticsMetaData> = {
|
||||
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
|
||||
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
|
||||
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
|
||||
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
|
||||
};
|
||||
|
||||
const NAMES = Object.fromEntries(
|
||||
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
|
||||
);
|
||||
|
||||
interface Example {
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "line-stack" | "bar" | "bar-stack";
|
||||
unit: string;
|
||||
hideLegend?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
description:
|
||||
"The change of a sum for each hour, like an energy statistics graph card.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
},
|
||||
{
|
||||
title: "Stacked bars",
|
||||
description: "Several sums stacked, to compare what each one adds.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar-stack",
|
||||
unit: "kWh",
|
||||
},
|
||||
{
|
||||
title: "Mean, min and max",
|
||||
description: "A measurement with its hourly mean, min and max.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
},
|
||||
{
|
||||
title: "Several statistics",
|
||||
description: "The mean of several measurements with the same unit.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
|
||||
statTypes: ["mean"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
},
|
||||
{
|
||||
title: "Without legend",
|
||||
description:
|
||||
"A single statistic with a fixed height, like in the more-info dialog.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
hideLegend: true,
|
||||
height: "240px",
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-statistics-chart")
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.hideLegend=${example.hideLegend ?? false}
|
||||
.height=${example.height}
|
||||
.clickForMoreInfo=${false}
|
||||
></statistics-chart>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-statistics-chart": DemoStatisticsChart;
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@babel/runtime": "8.0.10",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -54,14 +54,14 @@
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "8.0.2",
|
||||
"@formatjs/intl-datetimeformat": "8.0.1",
|
||||
"@formatjs/intl-displaynames": "8.0.1",
|
||||
"@formatjs/intl-durationformat": "0.12.3",
|
||||
"@formatjs/intl-durationformat": "0.12.2",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.0",
|
||||
"@formatjs/intl-listformat": "9.0.1",
|
||||
"@formatjs/intl-locale": "5.4.2",
|
||||
"@formatjs/intl-numberformat": "10.0.2",
|
||||
"@formatjs/intl-pluralrules": "7.0.2",
|
||||
"@formatjs/intl-numberformat": "10.0.1",
|
||||
"@formatjs/intl-pluralrules": "7.0.1",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.1",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.2",
|
||||
"@lezer/common": "1.5.5",
|
||||
@@ -80,7 +80,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.5.0",
|
||||
"@tsparticles/preset-links": "4.5.0",
|
||||
"@versatiles/style": "8.0.0",
|
||||
"@versatiles/style": "7.1.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -137,14 +137,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.1.0",
|
||||
"@babel/core": "8.0.10",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
@@ -170,7 +170,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.5",
|
||||
@@ -189,7 +189,7 @@
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "14.0.3",
|
||||
"globals": "17.14.0",
|
||||
"globals": "17.13.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -217,7 +217,7 @@
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.5",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.1.0",
|
||||
"vitest": "5.0.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
|
||||
Generated
+431
-431
File diff suppressed because it is too large.
Load diff
@@ -20,11 +20,6 @@ export interface EntityFilter {
|
||||
|
||||
export type EntityFilterFunc = (entityId: string) => boolean;
|
||||
|
||||
export type EntityFilterSources = Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>;
|
||||
|
||||
const normalizeFilterArray = <T>(
|
||||
value: T | null | T[] | (T | null)[] | undefined
|
||||
): Set<T | null> | undefined => {
|
||||
@@ -162,20 +157,3 @@ export const findEntities = (
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
sources.states,
|
||||
sources.entities,
|
||||
sources.devices,
|
||||
sources.areas,
|
||||
sources.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
};
|
||||
+2
-13
@@ -16,13 +16,6 @@ declare global {
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
/**
|
||||
* Leave open dialogs open. For a replace that only canonicalizes the current
|
||||
* URL, like a router filling in its default page: a dialog restored from the
|
||||
* URL, such as a more-info deep link, may already be open at that point, and
|
||||
* the entry keeps its state so back still closes it.
|
||||
*/
|
||||
keepDialogs?: boolean;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
@@ -203,8 +196,7 @@ const buildHistoryState = (
|
||||
};
|
||||
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const keepDialogs = options?.keepDialogs || false;
|
||||
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
}
|
||||
@@ -223,10 +215,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace && keepDialogs) {
|
||||
// Same entry under its canonical URL: its state, dialog included, stays.
|
||||
history.replaceState(history.state, "", path);
|
||||
} else if (replace) {
|
||||
if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
|
||||
@@ -20,7 +20,6 @@ export const entitiesFilterQueryParamConfig = {
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"device_class",
|
||||
"domain",
|
||||
"entity_domain",
|
||||
"label",
|
||||
@@ -51,7 +50,6 @@ export const entitiesFilterQueryParams = (
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
device_class: list(filters["ha-filter-device-classes"]),
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
entity_domain: list(filters["ha-filter-domains"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
@@ -116,7 +114,6 @@ export const entitiesLinkFromUrl = (
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-device-classes": values("device_class"),
|
||||
"ha-filter-integrations": values("domain"),
|
||||
"ha-filter-domains": values("entity_domain"),
|
||||
"ha-filter-labels": values("label"),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
AREA_CONTROLS_BUTTONS,
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
type AreaControlDomain,
|
||||
} from "../data/area/area_controls";
|
||||
import {
|
||||
@@ -149,20 +148,15 @@ 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
|
||||
AREA_CONTROL_DOMAINS,
|
||||
areaId,
|
||||
excludeEntities
|
||||
);
|
||||
|
||||
const items: (AreaControlPickerItem | string)[] = [];
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base";
|
||||
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
|
||||
import { styles } from "@material/mwc-list/mwc-list-item.css";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { preventDefault } from "../common/dom/prevent_default";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import "./ha-checkbox";
|
||||
|
||||
@customElement("ha-check-list-item")
|
||||
export class HaCheckListItem extends CheckListItemBase {
|
||||
@property({ type: Boolean, attribute: "checkbox-disabled" })
|
||||
checkboxDisabled = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
indeterminate = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "separate-checkbox-click" })
|
||||
separateCheckboxClick = false;
|
||||
|
||||
async onChange(event) {
|
||||
event.stopPropagation();
|
||||
super.onChange(event);
|
||||
fireEvent(this, event.type);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const text = this.renderText();
|
||||
const graphic =
|
||||
this.graphic && this.graphic !== "control" && !this.left
|
||||
? this.renderGraphic()
|
||||
: nothing;
|
||||
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
|
||||
const ripple = this.renderRipple();
|
||||
|
||||
return html` ${ripple} ${graphic} ${this.left ? "" : text}
|
||||
<ha-checkbox
|
||||
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
|
||||
.checked=${this.selected}
|
||||
.indeterminate=${this.indeterminate}
|
||||
?disabled=${this.disabled || this.checkboxDisabled}
|
||||
@change=${this.onChange}
|
||||
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
|
||||
class=${this.left ? "left" : ""}
|
||||
>
|
||||
</ha-checkbox>
|
||||
${this.left ? text : ""} ${meta}`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
styles,
|
||||
controlStyles,
|
||||
css`
|
||||
:host {
|
||||
--mdc-theme-secondary: var(--primary-color);
|
||||
}
|
||||
|
||||
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
|
||||
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
|
||||
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
|
||||
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
|
||||
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
|
||||
margin-inline-start: 0px;
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-checkbox {
|
||||
flex-shrink: 0;
|
||||
direction: var(--direction);
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
ha-checkbox.left {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.mdc-deprecated-list-item__graphic {
|
||||
margin-top: var(--check-list-item-graphic-margin-top);
|
||||
}
|
||||
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-check-list-item": HaCheckListItem;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,10 @@ import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
|
||||
import { computeDeviceClassName } from "../data/entity/device_class";
|
||||
import { computeUnitDeviceClasses } from "../data/sensor/unit_of_measurement";
|
||||
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
ValueChangedEvent,
|
||||
@@ -30,13 +27,6 @@ export const getDeviceClassOptions = (
|
||||
return { id: deviceClass, primary, sorting_label: primary };
|
||||
});
|
||||
|
||||
// The reason line of a device class that does not support the unit names the
|
||||
// unit, so searching it would match every unsupported device class
|
||||
const UNIT_FILTER_SEARCH_KEYS: FuseWeightedKey[] = [
|
||||
{ name: "primary", weight: 10 },
|
||||
{ name: "id", weight: 3 },
|
||||
];
|
||||
|
||||
@customElement("ha-device-class-picker")
|
||||
export class HaDeviceClassPicker extends LitElement {
|
||||
@property() public domain?: string;
|
||||
@@ -53,9 +43,6 @@ export class HaDeviceClassPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
/** Unit of measurement: device classes that do not support it are listed last */
|
||||
@property() public unit?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
@@ -83,60 +70,18 @@ export class HaDeviceClassPicker extends LitElement {
|
||||
private _options = memoizeOne(
|
||||
(
|
||||
domain: string | undefined,
|
||||
localize: LocalizeFunc | undefined,
|
||||
unit: string | undefined,
|
||||
language: string | undefined
|
||||
): PickerComboBoxItem[] => {
|
||||
if (!domain || !localize) {
|
||||
return [];
|
||||
}
|
||||
const options = getDeviceClassOptions(domain, localize);
|
||||
const fitting = computeUnitDeviceClasses(domain, unit);
|
||||
if (!fitting) {
|
||||
return options;
|
||||
}
|
||||
// Device classes of the unit first, then those that accept any unit
|
||||
const rank = (deviceClass: string) =>
|
||||
fitting.matching.includes(deviceClass)
|
||||
? 0
|
||||
: fitting.unrestricted.includes(deviceClass)
|
||||
? 1
|
||||
: 2;
|
||||
const notSupported = localize(
|
||||
"ui.components.device-class-picker.unit_not_supported",
|
||||
{ unit }
|
||||
);
|
||||
return options
|
||||
.map((option) =>
|
||||
rank(option.id) === 2
|
||||
? { ...option, secondary: notSupported }
|
||||
: option
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a.id) - rank(b.id) ||
|
||||
caseInsensitiveStringCompare(a.primary, b.primary, language)
|
||||
);
|
||||
}
|
||||
localize: LocalizeFunc | undefined
|
||||
): PickerComboBoxItem[] =>
|
||||
domain && localize ? getDeviceClassOptions(domain, localize) : []
|
||||
);
|
||||
|
||||
// The options are sorted here when the unit filters them
|
||||
private get _filtered(): boolean {
|
||||
return computeUnitDeviceClasses(this.domain, this.unit) !== undefined;
|
||||
}
|
||||
|
||||
private _availableOptions = memoizeOne(
|
||||
(options: PickerComboBoxItem[], selected: string[]) =>
|
||||
options.filter((option) => !selected.includes(option.id))
|
||||
);
|
||||
|
||||
private _getItems = () => {
|
||||
const options = this._options(
|
||||
this.domain,
|
||||
this._i18n?.localize,
|
||||
this.unit,
|
||||
this._i18n?.language
|
||||
);
|
||||
const options = this._options(this.domain, this._i18n?.localize);
|
||||
return this.multiple
|
||||
? this._availableOptions(options, this._value)
|
||||
: options;
|
||||
@@ -159,8 +104,6 @@ export class HaDeviceClassPicker extends LitElement {
|
||||
const emptyLabel = localize?.(
|
||||
"ui.components.device-class-picker.no_device_classes"
|
||||
);
|
||||
const filtered = this._filtered;
|
||||
const searchKeys = filtered ? UNIT_FILTER_SEARCH_KEYS : undefined;
|
||||
|
||||
if (this.multiple) {
|
||||
const value = this._value;
|
||||
@@ -202,8 +145,6 @@ export class HaDeviceClassPicker extends LitElement {
|
||||
.getItems=${this._getItems}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${emptyLabel}
|
||||
.noSort=${filtered}
|
||||
.searchKeys=${searchKeys}
|
||||
@value-changed=${this._itemAdded}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
@@ -223,8 +164,6 @@ export class HaDeviceClassPicker extends LitElement {
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${emptyLabel}
|
||||
.noSort=${filtered}
|
||||
.searchKeys=${searchKeys}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { computeDeviceClassName } from "../data/entity/device_class";
|
||||
import { HaFilterStates } from "./ha-filter-states";
|
||||
|
||||
@customElement("ha-filter-device-classes")
|
||||
export class HaFilterDeviceClasses extends HaFilterStates {
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _entityStates?: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize?: LocalizeFunc;
|
||||
|
||||
private _deviceClasses = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string
|
||||
) => {
|
||||
const classes = new Map<string, string>();
|
||||
|
||||
for (const [entityId, stateObj] of Object.entries(states)) {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
|
||||
if (typeof deviceClass !== "string" || classes.has(deviceClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
classes.set(
|
||||
deviceClass,
|
||||
computeDeviceClassName(localize, computeDomain(entityId), deviceClass)
|
||||
);
|
||||
}
|
||||
|
||||
return [...classes]
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => stringCompare(a.label, b.label, language));
|
||||
}
|
||||
);
|
||||
|
||||
protected firstUpdated() {
|
||||
this._i18n?.loadBackendTranslation("entity_component");
|
||||
}
|
||||
|
||||
protected willUpdate() {
|
||||
if (
|
||||
this._entityStates &&
|
||||
this._localize &&
|
||||
this._i18n &&
|
||||
(this.expanded || !this.states)
|
||||
) {
|
||||
this.states = this._deviceClasses(
|
||||
this._entityStates,
|
||||
this._localize,
|
||||
this._i18n.locale.language
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-device-classes": HaFilterDeviceClasses;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { DeviceClassSelector } from "../../data/selector";
|
||||
import { computeDeviceClassForUnit } from "../../data/sensor/unit_of_measurement";
|
||||
import "../ha-device-class-picker";
|
||||
|
||||
@customElement("ha-selector-device_class")
|
||||
@@ -20,17 +17,12 @@ export class HaDeviceClassSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
filter_unit_of_measurement?: string;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-device-class-picker
|
||||
.domain=${this.selector.device_class?.domain}
|
||||
.value=${this.value}
|
||||
.multiple=${this.selector.device_class?.multiple ?? false}
|
||||
.unit=${this.context?.filter_unit_of_measurement}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
@@ -39,39 +31,6 @@ export class HaDeviceClassSelector extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
// A unit does not imply a device class, so an optional field stays empty
|
||||
if (
|
||||
!this.required ||
|
||||
!changedProps.has("context") ||
|
||||
this.value ||
|
||||
this.selector.device_class?.multiple
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preselect only when the unit changes, so a form that opens with a unit
|
||||
// and a device class the user cleared stay without a device class
|
||||
const oldContext = changedProps.get("context");
|
||||
const unit = this.context?.filter_unit_of_measurement;
|
||||
if (
|
||||
oldContext === undefined ||
|
||||
oldContext.filter_unit_of_measurement === unit
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preselect the only device class of the unit
|
||||
const deviceClass = computeDeviceClassForUnit(
|
||||
this.selector.device_class?.domain,
|
||||
unit
|
||||
);
|
||||
if (deviceClass) {
|
||||
fireEvent(this, "value-changed", { value: deviceClass });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-device-class-picker {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
|
||||
import { generateEntityFilter } from "../../common/entity/entity_filter";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
@@ -62,26 +61,15 @@ 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[]
|
||||
controls: readonly AreaControlDomain[],
|
||||
areaId: string,
|
||||
excludeEntities: string[] | undefined
|
||||
): Record<AreaControlDomain, string[]> =>
|
||||
controls.reduce(
|
||||
(acc, control) => {
|
||||
@@ -93,12 +81,15 @@ export const getAreaControlEntities = (
|
||||
areas,
|
||||
floors,
|
||||
{
|
||||
area: areaId,
|
||||
entity_category: "none",
|
||||
...controlButton.filter,
|
||||
}
|
||||
);
|
||||
|
||||
acc[control] = areaEntities.filter(filter);
|
||||
acc[control] = Object.keys(entities).filter(
|
||||
(entityId) => filter(entityId) && !excludeEntities?.includes(entityId)
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AreaControlDomain, string[]>
|
||||
|
||||
@@ -390,32 +390,3 @@ export const getAutomaticEntityIds = (
|
||||
type: "config/entity_registry/get_automatic_entity_ids",
|
||||
entity_ids,
|
||||
});
|
||||
|
||||
/**
|
||||
* Set the area of entities and regenerate their entity IDs, which can include
|
||||
* the area. Returns the entity ID of each entity after the change.
|
||||
*/
|
||||
export const updateEntitiesArea = async (
|
||||
hass: HomeAssistant,
|
||||
entityIds: string[],
|
||||
areaId: string
|
||||
): Promise<Record<string, string>> => {
|
||||
await Promise.all(
|
||||
entityIds.map((entityId) =>
|
||||
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
|
||||
)
|
||||
);
|
||||
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
|
||||
const result: Record<string, string> = {};
|
||||
for (const entityId of entityIds) {
|
||||
const newEntityId = newEntityIds[entityId];
|
||||
if (newEntityId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await updateEntityRegistryEntry(hass, entityId, {
|
||||
new_entity_id: newEntityId,
|
||||
});
|
||||
}
|
||||
result[entityId] = newEntityId || entityId;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
+1
-2
@@ -19,8 +19,7 @@ export interface ESPHomeZWaveProxyCapabilities {
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
// `null` when no device is present (e.g. unpopulated USB port)
|
||||
url: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
|
||||
@@ -527,16 +527,10 @@ export interface SelectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Maps a context key to the name of another field or blueprint input
|
||||
export interface DeviceClassSelectorContext {
|
||||
filter_unit_of_measurement?: string;
|
||||
}
|
||||
|
||||
export interface DeviceClassSelector {
|
||||
device_class: {
|
||||
domain: string;
|
||||
multiple?: boolean;
|
||||
context?: DeviceClassSelectorContext;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
|
||||
import {
|
||||
SENSOR_DEVICE_CLASS_UNITS,
|
||||
SENSOR_NUMERIC_DEVICE_CLASSES,
|
||||
@@ -98,52 +97,3 @@ export const computeSelectorUnits = (
|
||||
}
|
||||
return fromConfig ?? fromContext;
|
||||
};
|
||||
|
||||
// Number entities share the unit tables of sensors
|
||||
const UNIT_DOMAINS = ["sensor", "number"];
|
||||
|
||||
export interface UnitDeviceClasses {
|
||||
/** Device classes whose unit table contains the unit */
|
||||
matching: string[];
|
||||
/** Numeric device classes without a unit table (e.g. `monetary`), which accept any unit */
|
||||
unrestricted: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which device classes of a domain accept a unit. A custom unit is in
|
||||
* no unit table, so only the device classes without a unit table accept it.
|
||||
*
|
||||
* @returns `undefined` when nothing is filtered: no unit, or a domain without units
|
||||
*/
|
||||
export const computeUnitDeviceClasses = (
|
||||
domain: string | undefined,
|
||||
unit: string | null | undefined
|
||||
): UnitDeviceClasses | undefined => {
|
||||
if (!unit || !domain || !UNIT_DOMAINS.includes(domain)) {
|
||||
return undefined;
|
||||
}
|
||||
const result: UnitDeviceClasses = { matching: [], unrestricted: [] };
|
||||
for (const deviceClass of DOMAIN_DEVICE_CLASSES[domain] ?? []) {
|
||||
const units = SENSOR_DEVICE_CLASS_UNITS[deviceClass];
|
||||
if (units) {
|
||||
if (units.includes(unit)) {
|
||||
result.matching.push(deviceClass);
|
||||
}
|
||||
} else if (SENSOR_NUMERIC_DEVICE_CLASSES.includes(deviceClass)) {
|
||||
result.unrestricted.push(deviceClass);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* The device class to preselect for a unit: the only one whose unit table
|
||||
* contains it.
|
||||
*/
|
||||
export const computeDeviceClassForUnit = (
|
||||
domain: string | undefined,
|
||||
unit: string | null | undefined
|
||||
): string | undefined => {
|
||||
const matching = computeUnitDeviceClasses(domain, unit)?.matching;
|
||||
return matching?.length === 1 ? matching[0] : undefined;
|
||||
};
|
||||
@@ -481,7 +481,6 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
.manifest=${this._params.manifest}
|
||||
.navigateToResult=${
|
||||
this._params.navigateToResult ?? false
|
||||
}
|
||||
@@ -596,7 +595,6 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.loading=${this._formStepLoading}
|
||||
@click=${this._finishCreateEntryStep}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -839,9 +837,6 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--ha-dialog-header-title-height: auto;
|
||||
}
|
||||
.dialog-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -20,18 +20,13 @@ import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
getAutomaticEntityIds,
|
||||
getExtendedEntityRegistryEntries,
|
||||
updateEntitiesArea,
|
||||
updateEntityRegistryEntry,
|
||||
type EntityRegistryDisplayEntry,
|
||||
type ExtEntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import { domainToName, type IntegrationManifest } from "../../data/integration";
|
||||
import { findRelated } from "../../data/search";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../more-info/show-ha-more-info-dialog";
|
||||
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
import { configFlowContentStyles } from "./styles";
|
||||
@@ -40,28 +35,6 @@ interface DeviceTarget {
|
||||
device: string;
|
||||
}
|
||||
|
||||
interface HelperResult {
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
const computeHelperResult = (
|
||||
entities: ExtEntityRegistryEntry[]
|
||||
): HelperResult | undefined => {
|
||||
const deviceIds = [
|
||||
...new Set(
|
||||
entities.flatMap((entity) => (entity.device_id ? [entity.device_id] : []))
|
||||
),
|
||||
];
|
||||
if (deviceIds.length === 1) {
|
||||
return { deviceId: deviceIds[0] };
|
||||
}
|
||||
if (!deviceIds.length && entities.length === 1) {
|
||||
return { entityId: entities[0].entity_id };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@customElement("step-flow-create-entry")
|
||||
class StepFlowCreateEntry extends LitElement {
|
||||
@property({ attribute: false }) public flowConfig!: FlowConfig;
|
||||
@@ -73,25 +46,15 @@ class StepFlowCreateEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public manifest?: IntegrationManifest | null;
|
||||
|
||||
private _domains: Record<string, string> = {};
|
||||
|
||||
public navigateToResult = false;
|
||||
|
||||
private _helperEntitiesLookup?: Promise<ExtEntityRegistryEntry[]>;
|
||||
|
||||
@state() private _helperEntities?: ExtEntityRegistryEntry[];
|
||||
|
||||
@state() private _helperArea?: string;
|
||||
|
||||
@state() private _deviceUpdate: Record<
|
||||
string,
|
||||
{ name?: string; area?: string }
|
||||
> = {};
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
private _deviceEntities = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
@@ -108,18 +71,6 @@ class StepFlowCreateEntry extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadDomains();
|
||||
if (
|
||||
this.flowConfig.flowType === "config_flow" &&
|
||||
this.step.result &&
|
||||
this.manifest?.integration_type === "helper"
|
||||
) {
|
||||
this._helperEntitiesLookup = this._findHelperEntities(
|
||||
this.step.result.entry_id
|
||||
);
|
||||
this._helperEntitiesLookup.then((entities) => {
|
||||
this._helperEntities = entities;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -180,23 +131,11 @@ class StepFlowCreateEntry extends LitElement {
|
||||
? nothing
|
||||
: this.devices.length === 0
|
||||
? html`<p>
|
||||
${localize(
|
||||
"ui.panel.config.integrations.config_flow.created_config",
|
||||
{ name: html`<b>${this.step.title}</b>` }
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._entitiesWithoutDevice(this._helperEntities).length
|
||||
? html`<ha-area-picker
|
||||
.value=${this._helperArea}
|
||||
.disabled=${this._loading}
|
||||
.helper=${localize(
|
||||
"ui.panel.config.integrations.config_flow.helper_area"
|
||||
)}
|
||||
@value-changed=${this._helperAreaPicked}
|
||||
></ha-area-picker>`
|
||||
: nothing
|
||||
}`
|
||||
${localize(
|
||||
"ui.panel.config.integrations.config_flow.created_config",
|
||||
{ name: this.step.title }
|
||||
)}
|
||||
</p>`
|
||||
: html`
|
||||
<div class="devices">
|
||||
${this.devices.map(
|
||||
@@ -273,25 +212,13 @@ class StepFlowCreateEntry extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_deviceUpdate") || changedProps.has("_helperArea")) {
|
||||
if (changedProps.has("_deviceUpdate")) {
|
||||
fireEvent(this, "flow-step-footer-state-changed", {
|
||||
hasPendingUpdates:
|
||||
Object.keys(this._deviceUpdate).length > 0 ||
|
||||
this._helperArea !== undefined,
|
||||
});
|
||||
}
|
||||
if (changedProps.has("_loading")) {
|
||||
fireEvent(this, "flow-step-footer-state-changed", {
|
||||
loading: this._loading,
|
||||
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _entitiesWithoutDevice = memoizeOne(
|
||||
(entities?: ExtEntityRegistryEntry[]): ExtEntityRegistryEntry[] =>
|
||||
(entities ?? []).filter((entity) => !entity.device_id)
|
||||
);
|
||||
|
||||
private async _loadDomains() {
|
||||
const entries = await getConfigEntries(this.hass);
|
||||
this._domains = Object.fromEntries(
|
||||
@@ -356,110 +283,20 @@ class StepFlowCreateEntry extends LitElement {
|
||||
await Promise.allSettled(entityUpdates);
|
||||
}
|
||||
|
||||
// Helpers link their entities to an existing device instead of creating one
|
||||
let helperEntities =
|
||||
this._helperEntitiesLookup && !this.devices.length
|
||||
? await this._helperEntitiesLookup
|
||||
: [];
|
||||
if (this._helperArea) {
|
||||
helperEntities = await this._saveHelperArea(
|
||||
helperEntities,
|
||||
this._helperArea
|
||||
);
|
||||
}
|
||||
|
||||
const entry = this.navigateToResult ? this.step.result : undefined;
|
||||
const helperResult = entry
|
||||
? computeHelperResult(helperEntities)
|
||||
: undefined;
|
||||
|
||||
// The dialog was closed while waiting
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "flow-update", { step: undefined });
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const deviceId =
|
||||
this.devices.length === 1 ? this.devices[0].id : helperResult?.deviceId;
|
||||
if (deviceId) {
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
} else if (helperResult?.entityId) {
|
||||
showMoreInfoDialog(this, { entityId: helperResult.entityId });
|
||||
} else {
|
||||
navigate(
|
||||
`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _findHelperEntities(
|
||||
entryId: string
|
||||
): Promise<ExtEntityRegistryEntry[]> {
|
||||
try {
|
||||
const { entity: entityIds = [] } = await findRelated(
|
||||
this.hass,
|
||||
"config_entry",
|
||||
entryId
|
||||
);
|
||||
if (!entityIds.length) {
|
||||
return [];
|
||||
if (this.step.result && this.navigateToResult) {
|
||||
if (this.devices.length === 1) {
|
||||
navigate(`/config/devices/device/${this.devices[0].id}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
|
||||
);
|
||||
}
|
||||
return Object.values(
|
||||
await getExtendedEntityRegistryEntries(this.hass, entityIds)
|
||||
).filter(
|
||||
(entity): entity is ExtEntityRegistryEntry =>
|
||||
entity?.config_entry_id === entryId
|
||||
);
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveHelperArea(
|
||||
entities: ExtEntityRegistryEntry[],
|
||||
areaId: string
|
||||
): Promise<ExtEntityRegistryEntry[]> {
|
||||
try {
|
||||
const newEntityIds = await updateEntitiesArea(
|
||||
this.hass,
|
||||
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
|
||||
areaId
|
||||
);
|
||||
return entities.map((entity) =>
|
||||
entity.entity_id in newEntityIds
|
||||
? { ...entity, entity_id: newEntityIds[entity.entity_id] }
|
||||
: entity
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error_saving_entity",
|
||||
{ error: (err as { message?: string })?.message ?? "Unknown error" }
|
||||
),
|
||||
});
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
private _helperAreaPicked(ev: ValueChangedEvent<string | undefined>): void {
|
||||
this._helperArea = ev.detail.value || undefined;
|
||||
}
|
||||
|
||||
public async finish(): Promise<void> {
|
||||
if (this._loading) {
|
||||
return;
|
||||
}
|
||||
// Stay loading until the dialog closes, so a second click cannot save
|
||||
// the updates again
|
||||
this._loading = true;
|
||||
try {
|
||||
await this._flowDone();
|
||||
} catch (err) {
|
||||
this._loading = false;
|
||||
throw err;
|
||||
}
|
||||
public finish(): Promise<void> {
|
||||
return this._flowDone();
|
||||
}
|
||||
|
||||
private async _areaPicked(ev: ValueChangedEvent<string>) {
|
||||
|
||||
@@ -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 | null) {
|
||||
private _serialPortUsage(url: string) {
|
||||
if (!this._serialPorts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -375,16 +375,21 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.rows {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--ha-space-4) var(--ha-space-1);
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: contents;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.row > :only-child {
|
||||
grid-column: 1 / -1;
|
||||
.row > *:first-child {
|
||||
flex: 1;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.row ha-button {
|
||||
width: 82px;
|
||||
}
|
||||
|
||||
ha-select {
|
||||
|
||||
@@ -140,15 +140,11 @@ window.hassConnection.then(({ conn }) => {
|
||||
const preloadWindow = window as WindowWithPreloads;
|
||||
preloadWindow.recorderInfoProm = getRecorderInfo(conn);
|
||||
|
||||
// "/" opens the default panel, which is not known yet, so only preload the
|
||||
// config when the "lovelace" dashboard is opened directly.
|
||||
if (location.pathname.startsWith("/lovelace/")) {
|
||||
preloadWindow.llConfProm = fetchConfig(conn, "lovelace", false);
|
||||
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
|
||||
preloadWindow.llConfProm = fetchConfig(conn, null, false);
|
||||
preloadWindow.llConfProm.catch(() => {
|
||||
// Ignore it, it is handled by Lovelace panel.
|
||||
});
|
||||
}
|
||||
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
|
||||
preloadWindow.llResProm = fetchResources(conn);
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,6 @@ 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>;
|
||||
@@ -18,11 +17,6 @@ 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;
|
||||
};
|
||||
|
||||
@@ -21,11 +21,9 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 560px;
|
||||
max-width: min(560px, calc(100vw - var(--safe-area-inset-right, 0px) - var(--safe-area-inset-left, 0px)));
|
||||
margin: 0 auto;
|
||||
padding: 0 calc(var(--ha-space-4, 16px) + var(--safe-area-inset-right, 0px))
|
||||
var(--safe-area-inset-bottom, 0px)
|
||||
calc(var(--ha-space-4, 16px) + var(--safe-area-inset-left, 0px));
|
||||
padding: 0 16px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ export class HassRouterPage extends ReactiveElement {
|
||||
const queryParams = window.location.search;
|
||||
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
|
||||
replace: true,
|
||||
keepDialogs: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +134,6 @@ export class HassRouterPage extends ReactiveElement {
|
||||
if (route) {
|
||||
navigate(`${route.prefix}/${result}${location.search}`, {
|
||||
replace: true,
|
||||
keepDialogs: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
const { pathname, search, hash } = mainWindow.location;
|
||||
navigate(
|
||||
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
|
||||
{ replace: true, keepDialogs: true }
|
||||
{ replace: true }
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -524,9 +524,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
|
||||
--ha-progress-bar-border-radius: 0;
|
||||
--ha-progress-bar-track-height: 4px;
|
||||
position: fixed;
|
||||
top: var(--safe-area-inset-top, 0px);
|
||||
left: var(--safe-area-inset-left, 0px);
|
||||
right: var(--safe-area-inset-right, 0px);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
.footer {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -19,12 +18,11 @@ import {
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-alert";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-list";
|
||||
import "../components/ha-list-item";
|
||||
import "../components/ha-spinner";
|
||||
import "../components/input/ha-input";
|
||||
import type { HaInput } from "../components/input/ha-input";
|
||||
import "../components/item/ha-list-item-base";
|
||||
import "../components/item/ha-list-item-button";
|
||||
import "../components/list/ha-list-base";
|
||||
import "../components/map/ha-locations-editor";
|
||||
import type {
|
||||
HaLocationsEditor,
|
||||
@@ -143,11 +141,7 @@ class OnboardingLocation extends LitElement {
|
||||
${
|
||||
this._places !== undefined
|
||||
? html`
|
||||
<ha-list-base
|
||||
aria-label=${this._localize(
|
||||
"ui.panel.page-onboarding.core-config.search_results"
|
||||
)}
|
||||
>
|
||||
<ha-list activatable>
|
||||
${
|
||||
this._places?.length
|
||||
? this._places.map((place) => {
|
||||
@@ -170,28 +164,26 @@ class OnboardingLocation extends LitElement {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return html`<ha-list-item-button
|
||||
return html`<ha-list-item
|
||||
@click=${this._itemClicked}
|
||||
.placeId=${place.place_id}
|
||||
aria-current=${this._highlightedMarker === place.place_id ? "true" : nothing}
|
||||
class=${classMap({
|
||||
selected:
|
||||
this._highlightedMarker === place.place_id,
|
||||
})}
|
||||
.selected=${this._highlightedMarker === place.place_id}
|
||||
.activated=${this._highlightedMarker === place.place_id}
|
||||
.twoline=${primary && secondary}
|
||||
>
|
||||
<span slot="headline">${primary || secondary}</span>
|
||||
<span slot="supporting-text"
|
||||
${primary || secondary}
|
||||
<span slot="secondary"
|
||||
>${primary ? secondary : ""}</span
|
||||
>
|
||||
</ha-list-item-button>`;
|
||||
</ha-list-item>`;
|
||||
})
|
||||
: html`<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this._places === null ? "" : "No results"}</span
|
||||
>
|
||||
</ha-list-item-base>`
|
||||
: html`<ha-list-item noninteractive
|
||||
>${
|
||||
this._places === null ? "" : "No results"
|
||||
}</ha-list-item
|
||||
>`
|
||||
}
|
||||
</ha-list-base>
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
@@ -522,21 +514,17 @@ class OnboardingLocation extends LitElement {
|
||||
border-radius: var(--mdc-shape-large, 16px);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-list-base {
|
||||
ha-list {
|
||||
width: 100%;
|
||||
border: 1px solid var(--divider-color);
|
||||
box-sizing: border-box;
|
||||
border-top-width: 0;
|
||||
border-bottom-left-radius: var(--mdc-shape-small, 4px);
|
||||
border-bottom-right-radius: var(--mdc-shape-small, 4px);
|
||||
overflow: hidden;
|
||||
--ha-row-item-min-height: 72px;
|
||||
--mdc-list-vertical-padding: 0;
|
||||
}
|
||||
ha-list-item-button.selected::part(base) {
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
}
|
||||
ha-list-item-button.selected::part(headline) {
|
||||
color: var(--primary-color);
|
||||
ha-list-item {
|
||||
height: 72px;
|
||||
}
|
||||
.attribution {
|
||||
/* input helper style */
|
||||
|
||||
@@ -237,11 +237,7 @@ class HaPanelApp extends LitElement {
|
||||
} catch (err: any) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addonSlug,
|
||||
err?.code === "unauthorized"
|
||||
? this.hass.localize("ui.panel.app.error_app_unauthorized")
|
||||
: this.hass.localize("ui.panel.app.error_loading_app", {
|
||||
error: extractApiErrorMessage(err),
|
||||
})
|
||||
extractApiErrorMessage(err)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -321,15 +317,13 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
try {
|
||||
session = await createSessionPromise;
|
||||
} catch (err: any) {
|
||||
} catch (_err: any) {
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
}
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
this.hass.localize("ui.panel.app.error_creating_session", {
|
||||
error: extractApiErrorMessage(err),
|
||||
})
|
||||
this.hass.localize("ui.panel.app.error_creating_session")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -311,9 +311,6 @@ export class CloudRemotePref extends LitElement {
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: normal;
|
||||
}
|
||||
ha-tip {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -67,7 +67,6 @@ import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-entity-id-icon";
|
||||
import "../../../components/ha-filter-device-classes";
|
||||
import "../../../components/ha-filter-devices";
|
||||
import "../../../components/ha-filter-domains";
|
||||
import "../../../components/ha-filter-floor-areas";
|
||||
@@ -88,7 +87,6 @@ import type {
|
||||
DataTableFiltersItems,
|
||||
DataTableFiltersValues,
|
||||
} from "../../../data/data_table_filters";
|
||||
import { computeDeviceClassName } from "../../../data/entity/device_class";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
@@ -162,7 +160,6 @@ export interface EntityRow extends StateEntity {
|
||||
status: string | undefined;
|
||||
area?: string;
|
||||
device?: string;
|
||||
device_class: string | null;
|
||||
device_full?: string;
|
||||
localized_platform: string;
|
||||
domain: string;
|
||||
@@ -301,22 +298,6 @@ export class HaConfigEntities extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _getDeviceClass(entry: StateEntity): string | null {
|
||||
return this.hass.states[entry.entity_id]?.attributes.device_class ?? null;
|
||||
}
|
||||
|
||||
private _getDeviceClassName(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string,
|
||||
deviceClass: string
|
||||
): string {
|
||||
return computeDeviceClassName(
|
||||
localize,
|
||||
computeDomain(entityId),
|
||||
deviceClass
|
||||
);
|
||||
}
|
||||
|
||||
private _states = memoize((localize: LocalizeFunc) => [
|
||||
{
|
||||
value: "available",
|
||||
@@ -411,13 +392,6 @@ export class HaConfigEntities extends LitElement {
|
||||
filterable: true,
|
||||
},
|
||||
domain: getDomainTableColumn(localize),
|
||||
device_class: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.device_class"),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
defaultHidden: true,
|
||||
template: (entry) => entry.device_class || "—",
|
||||
},
|
||||
disabled_by: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.disabled_by"),
|
||||
hidden: true,
|
||||
@@ -655,15 +629,6 @@ export class HaConfigEntities extends LitElement {
|
||||
entryIds.includes(entity.config_entry_id))
|
||||
);
|
||||
filter!.forEach((domain) => filteredDomains.add(domain));
|
||||
} else if (
|
||||
key === "ha-filter-device-classes" &&
|
||||
Array.isArray(filter) &&
|
||||
filter.length
|
||||
) {
|
||||
filteredEntities = filteredEntities.filter((entry) => {
|
||||
const deviceClass = this._getDeviceClass(entry);
|
||||
return deviceClass != null && filter.includes(deviceClass);
|
||||
});
|
||||
} else if (
|
||||
key === "ha-filter-domains" &&
|
||||
Array.isArray(filter) &&
|
||||
@@ -748,13 +713,9 @@ export class HaConfigEntities extends LitElement {
|
||||
exposedEntities,
|
||||
entry.entity_id
|
||||
);
|
||||
const deviceClass = this._getDeviceClass(entry);
|
||||
|
||||
result.push({
|
||||
...entry,
|
||||
device_class: deviceClass
|
||||
? this._getDeviceClassName(localize, entry.entity_id, deviceClass)
|
||||
: null,
|
||||
name:
|
||||
entityName ||
|
||||
deviceName ||
|
||||
@@ -1103,17 +1064,6 @@ export class HaConfigEntities extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-devices>
|
||||
<ha-filter-device-classes
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.entities.picker.headers.device_class"
|
||||
)}
|
||||
.value=${this._filters["ha-filter-device-classes"]}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-device-classes"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-device-classes>
|
||||
<ha-filter-domains
|
||||
.value=${this._filters["ha-filter-domains"]}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
@@ -1213,7 +1163,6 @@ export class HaConfigEntities extends LitElement {
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.hass.loadBackendTranslation("entity_component");
|
||||
this._fetchExposedEntities();
|
||||
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
|
||||
(sources) => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import "../../../components/ha-area-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
@@ -29,12 +28,6 @@ import type { HaListSelectable } from "../../../components/list/ha-list-selectab
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { getConfigFlowHandlers } from "../../../data/config_flow";
|
||||
import { createCounter } from "../../../data/counter";
|
||||
import {
|
||||
fetchEntityRegistry,
|
||||
getExtendedEntityRegistryEntry,
|
||||
updateEntitiesArea,
|
||||
type EntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { createInputBoolean } from "../../../data/input_boolean";
|
||||
import { createInputButton } from "../../../data/input_button";
|
||||
import { createInputDateTime } from "../../../data/input_datetime";
|
||||
@@ -50,9 +43,8 @@ import { createSchedule } from "../../../data/schedule";
|
||||
import { createTimer } from "../../../data/timer";
|
||||
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import type { Helper, HelperDomain, HelperFormItem } from "./const";
|
||||
import { isHelperDomain } from "./const";
|
||||
@@ -118,14 +110,12 @@ const HELPERS: HelperCreators = {
|
||||
|
||||
@customElement("dialog-helper-detail")
|
||||
export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
{ item?: HelperFormItem; area?: string } | undefined
|
||||
HelperFormItem | undefined
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _item?: HelperFormItem;
|
||||
|
||||
@state() private _area?: string;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _domain?: string;
|
||||
@@ -147,7 +137,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
startFlowHandler: string;
|
||||
manifest: IntegrationManifest;
|
||||
dialogClosedCallback?: ShowDialogHelperDetailParams["dialogClosedCallback"];
|
||||
navigateToResult?: boolean;
|
||||
};
|
||||
|
||||
private _params?: ShowDialogHelperDetailParams;
|
||||
@@ -156,15 +145,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._params = params;
|
||||
this._domain = params.domain;
|
||||
this._item = params.item;
|
||||
this._area = undefined;
|
||||
if (this._domain && this._domain in HELPERS) {
|
||||
this._loading = true;
|
||||
try {
|
||||
await HELPERS[this._domain].import();
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ item: this._item, area: undefined }
|
||||
);
|
||||
this._initDirtyTracking({ type: "deep" }, undefined);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
@@ -200,7 +185,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
startFlowHandler: pendingConfigFlow.startFlowHandler,
|
||||
manifest: pendingConfigFlow.manifest,
|
||||
dialogClosedCallback: pendingConfigFlow.dialogClosedCallback,
|
||||
navigateToResult: pendingConfigFlow.navigateToResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -222,11 +206,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
new: true,
|
||||
autofocus: true,
|
||||
})}
|
||||
<ha-area-picker
|
||||
.value=${this._area}
|
||||
.disabled=${this._submitting}
|
||||
@value-changed=${this._areaChanged}
|
||||
></ha-area-picker>
|
||||
</div>
|
||||
`;
|
||||
footer = html`
|
||||
@@ -401,45 +380,28 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._item = ev.detail.value;
|
||||
this._updateDirtyState({ item: this._item, area: this._area });
|
||||
this._updateDirtyState(this._item);
|
||||
}
|
||||
|
||||
private async _createItem(): Promise<void> {
|
||||
if (!this._domain || !this._item) {
|
||||
return;
|
||||
}
|
||||
const domain = this._domain;
|
||||
const params = this._params;
|
||||
const area = this._area;
|
||||
this._submitting = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const createdEntity = await HELPERS[domain].create(this.hass, this._item);
|
||||
// The dialog was closed or reopened while waiting
|
||||
if (this._params !== params) {
|
||||
return;
|
||||
}
|
||||
let entityId: string | undefined;
|
||||
if (createdEntity.id && (area || params?.navigateToResult)) {
|
||||
entityId = await this._findEntityId(domain, createdEntity.id);
|
||||
if (area) {
|
||||
entityId = await this._saveArea(entityId, area);
|
||||
}
|
||||
if (this._params !== params) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (params?.dialogClosedCallback && createdEntity.id) {
|
||||
params.dialogClosedCallback({
|
||||
const createdEntity = await HELPERS[this._domain].create(
|
||||
this.hass,
|
||||
this._item
|
||||
);
|
||||
if (this._params?.dialogClosedCallback && createdEntity.id) {
|
||||
this._params.dialogClosedCallback({
|
||||
flowFinished: true,
|
||||
entityId: entityId ?? `${domain}.${createdEntity.id}`,
|
||||
entityId: `${this._domain}.${createdEntity.id}`,
|
||||
});
|
||||
}
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
if (params?.navigateToResult && entityId) {
|
||||
showMoreInfoDialog(this, { entityId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Unknown error";
|
||||
} finally {
|
||||
@@ -447,57 +409,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
}
|
||||
|
||||
private async _findEntityId(
|
||||
domain: string,
|
||||
uniqueId: string
|
||||
): Promise<string | undefined> {
|
||||
const isCreated = (entry: EntityRegistryEntry) =>
|
||||
entry.platform === domain && entry.unique_id === uniqueId;
|
||||
try {
|
||||
// The entity ID usually matches the helper ID, unless it was taken
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass,
|
||||
`${domain}.${uniqueId}`
|
||||
).catch(() => undefined);
|
||||
if (entry && isCreated(entry)) {
|
||||
return entry.entity_id;
|
||||
}
|
||||
return (await fetchEntityRegistry(this.hass.connection)).find(isCreated)
|
||||
?.entity_id;
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveArea(
|
||||
entityId: string | undefined,
|
||||
areaId: string
|
||||
): Promise<string | undefined> {
|
||||
let error = "Entity not found";
|
||||
if (entityId) {
|
||||
try {
|
||||
return (await updateEntitiesArea(this.hass, [entityId], areaId))[
|
||||
entityId
|
||||
];
|
||||
} catch (err: unknown) {
|
||||
error = (err as { message?: string })?.message ?? "Unknown error";
|
||||
}
|
||||
}
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error_saving_entity",
|
||||
{ error }
|
||||
),
|
||||
});
|
||||
return entityId;
|
||||
}
|
||||
|
||||
private _areaChanged(ev: ValueChangedEvent<string | undefined>): void {
|
||||
ev.stopPropagation();
|
||||
this._area = ev.detail.value || undefined;
|
||||
this._updateDirtyState({ item: this._item, area: this._area });
|
||||
}
|
||||
|
||||
private async _domainPicked(
|
||||
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
|
||||
): Promise<void> {
|
||||
@@ -524,10 +435,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
try {
|
||||
await HELPERS[domain].import();
|
||||
this._domain = domain;
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ item: this._item, area: undefined }
|
||||
);
|
||||
this._initDirtyTracking({ type: "deep" }, undefined);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
@@ -536,7 +444,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
startFlowHandler: domain,
|
||||
manifest: await fetchIntegrationManifest(this.hass, domain),
|
||||
dialogClosedCallback: this._params?.dialogClosedCallback,
|
||||
navigateToResult: this._params?.navigateToResult,
|
||||
};
|
||||
this.closeDialog();
|
||||
}
|
||||
@@ -545,7 +452,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
private async _goBack() {
|
||||
this._domain = undefined;
|
||||
this._item = undefined;
|
||||
this._area = undefined;
|
||||
this._error = undefined;
|
||||
await this.updateComplete;
|
||||
await this._focusSearchInput();
|
||||
@@ -584,10 +490,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
.form {
|
||||
padding: var(--ha-space-6);
|
||||
}
|
||||
.form ha-area-picker {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
ha-input-search {
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
|
||||
@@ -1300,7 +1300,6 @@ ${rejected
|
||||
if (isHelperDomain(domain)) {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
navigateToResult: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1344,7 +1343,6 @@ ${rejected
|
||||
showConfigFlowDialog(this, {
|
||||
startFlowHandler: domain,
|
||||
manifest: await fetchIntegrationManifest(this.hass, domain),
|
||||
navigateToResult: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1570,12 +1568,11 @@ ${rejected
|
||||
{ name: config.name }
|
||||
),
|
||||
},
|
||||
navigateToResult: true,
|
||||
});
|
||||
}
|
||||
|
||||
private _createHelper() {
|
||||
showHelperDetailDialog(this, { navigateToResult: true });
|
||||
showHelperDetailDialog(this, {});
|
||||
}
|
||||
|
||||
private _bulkCreateCategory = () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface ShowDialogHelperDetailParams {
|
||||
entryId?: string;
|
||||
entityId?: string;
|
||||
}) => void;
|
||||
navigateToResult?: boolean;
|
||||
}
|
||||
|
||||
export const showHelperDetailDialog = (
|
||||
|
||||
@@ -124,7 +124,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
private _loadId = 0;
|
||||
|
||||
public async showDialog(params?: AddIntegrationDialogParams): Promise<void> {
|
||||
this._navigateToResult = params?.navigateToResult ?? false;
|
||||
const loadPromise = this._load(params?.integrationFilter);
|
||||
|
||||
if (params?.domain) {
|
||||
@@ -160,6 +159,7 @@ class AddIntegrationDialog extends LitElement {
|
||||
this._openedDirectly = !!(params?.brand || params?.domain);
|
||||
this._initialFilter = params?.initialFilter;
|
||||
this._integrationFilter = params?.integrationFilter;
|
||||
this._navigateToResult = params?.navigateToResult ?? false;
|
||||
this._narrow = matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
mdiBugPlay,
|
||||
mdiBugStop,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiFileCodeOutline,
|
||||
mdiHelpCircleOutline,
|
||||
mdiOpenInNew,
|
||||
@@ -173,10 +172,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _logInfo?: IntegrationLogInfo;
|
||||
|
||||
@state() private _debugLogReady = false;
|
||||
|
||||
private _debugLogRequest = 0;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(
|
||||
__DEMO__ ? window.location.search : window.location.hash.substring(1)
|
||||
);
|
||||
@@ -233,7 +228,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("hashchange", this._handleHashChange);
|
||||
this._resetDebugLogReady();
|
||||
}
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
@@ -244,12 +238,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
subscribeLogInfo(this.hass.connection, (log_infos) => {
|
||||
for (const log_info of log_infos) {
|
||||
if (log_info.domain === this.domain) {
|
||||
if (
|
||||
log_info.level === LogSeverity.DEBUG &&
|
||||
this._logInfo?.level !== LogSeverity.DEBUG
|
||||
) {
|
||||
this._resetDebugLogReady();
|
||||
}
|
||||
this._logInfo = log_info;
|
||||
}
|
||||
}
|
||||
@@ -263,7 +251,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this.hass.loadBackendTranslation("config", [this.domain]);
|
||||
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
|
||||
this._extraConfigEntries = undefined;
|
||||
this._resetDebugLogReady();
|
||||
this._filter = "";
|
||||
this._subEntries = {};
|
||||
this._fetchManifest();
|
||||
@@ -853,34 +840,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
</ha-alert>
|
||||
</div>`
|
||||
: this._debugLogReady
|
||||
? html`<div class="section">
|
||||
<ha-alert
|
||||
alert-type="info"
|
||||
dismissable
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.debug_log_ready"
|
||||
)}
|
||||
@alert-dismissed-clicked=${this._resetDebugLogReady}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.debug_log_ready_description"
|
||||
)}
|
||||
<br /><br />
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@click=${this._downloadDebugLog}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDownload}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.download")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
</div>`
|
||||
: nothing
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
filteredDiscoveryData.length
|
||||
@@ -1421,19 +1381,16 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _handleDisableDebugLogging(ev: Event) {
|
||||
// Stop propagation since otherwise we end up here twice while we await the log level change.
|
||||
// Stop propagation since otherwise we end up here twice while we await the log level change
|
||||
// and trigger two identical debug log downloads.
|
||||
ev.stopPropagation();
|
||||
const integration = this.domain;
|
||||
const request = this._debugLogRequest;
|
||||
await setIntegrationLogLevel(
|
||||
this.hass,
|
||||
integration,
|
||||
LogSeverity[LogSeverity.NOTSET],
|
||||
"once"
|
||||
);
|
||||
if (request !== this._debugLogRequest) {
|
||||
return;
|
||||
}
|
||||
const logFileDownloadUnavailableReason =
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass);
|
||||
if (logFileDownloadUnavailableReason) {
|
||||
@@ -1447,17 +1404,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._debugLogReady = true;
|
||||
}
|
||||
|
||||
// Also invalidates pending disable requests so they can't restore the alert.
|
||||
private _resetDebugLogReady() {
|
||||
this._debugLogReady = false;
|
||||
this._debugLogRequest++;
|
||||
}
|
||||
|
||||
private async _downloadDebugLog() {
|
||||
const integration = this.domain;
|
||||
const timeString = new Date().toISOString().replace(/:/g, "-");
|
||||
const logFileName = `home-assistant_${integration}_${timeString}.log`;
|
||||
const signedUrl = await getSignedPath(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_da
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import type { HASSDomTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import { escapeRegExp } from "../../../../common/string/escape_regexp";
|
||||
import { computeEntitySearchLabels } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
@@ -131,7 +132,6 @@ class HaPanelDevState extends LitElement {
|
||||
attributeFilter: string,
|
||||
deviceFilter: string,
|
||||
areaFilter: string,
|
||||
deviceColumnShown: boolean,
|
||||
states: HassEntities,
|
||||
entities: HomeAssistantRegistries["entities"],
|
||||
devices: HomeAssistantRegistries["devices"],
|
||||
@@ -143,7 +143,6 @@ class HaPanelDevState extends LitElement {
|
||||
attributeFilter,
|
||||
deviceFilter,
|
||||
areaFilter,
|
||||
deviceColumnShown,
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
@@ -158,7 +157,6 @@ class HaPanelDevState extends LitElement {
|
||||
this._attributeFilter,
|
||||
this._deviceFilter,
|
||||
this._areaFilter,
|
||||
!this.narrow && this._showDevice,
|
||||
this._states,
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
@@ -495,7 +493,6 @@ class HaPanelDevState extends LitElement {
|
||||
attributeFilter: string,
|
||||
deviceFilter: string,
|
||||
areaFilter: string,
|
||||
deviceColumnShown: boolean,
|
||||
states: HassEntities,
|
||||
entities: HomeAssistantRegistries["entities"],
|
||||
devices: HomeAssistantRegistries["devices"],
|
||||
@@ -543,16 +540,13 @@ class HaPanelDevState extends LitElement {
|
||||
|
||||
return Object.values(states)
|
||||
.filter((value) => {
|
||||
// Match the entity column's name; without the device column, match
|
||||
// the full name.
|
||||
if (
|
||||
entityFilterRegExp &&
|
||||
!entityFilterRegExp.test(value.entity_id) &&
|
||||
!entityFilterRegExp.test(this.hass.formatEntityName(value)) &&
|
||||
!entityFilterRegExp.test(
|
||||
this.hass.formatEntityName(
|
||||
value,
|
||||
deviceColumnShown ? { type: "entity" } : undefined
|
||||
)
|
||||
computeEntitySearchLabels(value, this._registries).friendlyName ??
|
||||
""
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -27,7 +27,6 @@ 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";
|
||||
@@ -237,8 +236,6 @@ 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(
|
||||
@@ -248,23 +245,17 @@ class HuiAreaControlsCardFeature
|
||||
excludeEntities: string[] | undefined,
|
||||
registries: HomeAssistantRegistries,
|
||||
states: HassEntities
|
||||
) => {
|
||||
const areaEntities = this._areaEntityIds(
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
areaId,
|
||||
excludeEntities
|
||||
);
|
||||
return getAreaControlEntities(
|
||||
areaEntities,
|
||||
) =>
|
||||
getAreaControlEntities(
|
||||
states,
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors,
|
||||
controls
|
||||
);
|
||||
}
|
||||
controls,
|
||||
areaId,
|
||||
excludeEntities
|
||||
)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -51,13 +51,11 @@ import {
|
||||
filterLowBatteryEntities,
|
||||
filterProblemEntities,
|
||||
filterUnavailableBatteryEntities,
|
||||
findMaintenanceEntities,
|
||||
} from "../../maintenance/strategies/maintenance-view-strategy";
|
||||
import {
|
||||
isSecurityAlertActive,
|
||||
resolveSecurityAlertSeverity,
|
||||
} from "../../security/strategies/security-alerts";
|
||||
import { findSecurityEntities } from "../../security/strategies/security-view-strategy";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { HomeSummaryCard } from "./types";
|
||||
@@ -233,13 +231,11 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
case "security": {
|
||||
// Alarm and lock status
|
||||
const securityEntities = findSecurityEntities({
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
});
|
||||
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
|
||||
generateEntityFilter(states, entities, devices, areas, floors, filter)
|
||||
);
|
||||
|
||||
const securityEntities = findEntities(allEntities, securityFilters);
|
||||
|
||||
const locks = securityEntities.filter((entityId) => {
|
||||
const domain = computeDomain(entityId);
|
||||
@@ -322,13 +318,22 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
: localize("ui.card.home-summary.no_media_playing");
|
||||
}
|
||||
case "maintenance": {
|
||||
const maintenanceEntities = findMaintenanceEntities({
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
});
|
||||
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
|
||||
(filter) =>
|
||||
generateEntityFilter(
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
const maintenanceEntities = findEntities(
|
||||
allEntities,
|
||||
maintenanceFilters
|
||||
);
|
||||
|
||||
const lowBatteryEntities = filterLowBatteryEntities(
|
||||
states,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -14,11 +13,6 @@ 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"]>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,23 +120,9 @@ export const handleAction = async (
|
||||
}
|
||||
case "navigate":
|
||||
if (actionConfig.navigation_path) {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
navigate(actionConfig.navigation_path, {
|
||||
replace: actionConfig.navigation_replace,
|
||||
});
|
||||
} else {
|
||||
showToast(node, {
|
||||
message: hass.localize(
|
||||
|
||||
@@ -16,7 +16,6 @@ import "../../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
MAX_DEFAULT_AREA_CONTROLS,
|
||||
} from "../../../../data/area/area_controls";
|
||||
import {
|
||||
@@ -65,20 +64,15 @@ 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
|
||||
AREA_CONTROL_DOMAINS,
|
||||
areaId,
|
||||
excludeEntities
|
||||
);
|
||||
return (
|
||||
Object.keys(controlEntities) as (keyof typeof controlEntities)[]
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
saveConfig,
|
||||
} from "../../data/lovelace/config/types";
|
||||
import { fetchResources } from "../../data/lovelace/resource";
|
||||
import { LOVELACE_PANEL } from "../../data/panel";
|
||||
import type { WindowWithPreloads } from "../../data/preloads";
|
||||
import "../../layouts/hass-error-screen";
|
||||
import "../../layouts/hass-loading-screen";
|
||||
@@ -84,11 +83,7 @@ export class LovelacePanel extends LitElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._fetchConfigOnConnect) {
|
||||
// Config was changed when we were not at the lovelace panel
|
||||
this._fetchConfigOnConnect = false;
|
||||
this._fetchConfig(false);
|
||||
} else if (
|
||||
if (
|
||||
this.lovelace &&
|
||||
this.hass &&
|
||||
this.lovelace.locale !== this.hass.locale
|
||||
@@ -99,15 +94,17 @@ export class LovelacePanel extends LitElement {
|
||||
this.lovelace.rawConfig,
|
||||
this.lovelace.mode
|
||||
);
|
||||
} else if (this._fetchConfigOnConnect) {
|
||||
// Config was changed when we were not at the lovelace panel
|
||||
this._fetchConfig(false);
|
||||
}
|
||||
window.addEventListener("connection-status", this._handleConnectionStatus);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
// The "lovelace" panel is cached by the panel resolver, stay subscribed so
|
||||
// we refetch its config when it is shown again.
|
||||
if (this.urlPath !== LOVELACE_PANEL && this._unsubUpdates) {
|
||||
// On the main dashboard we want to stay subscribed as that one is cached.
|
||||
if (this.urlPath !== null && this._unsubUpdates) {
|
||||
this._unsubUpdates.then((unsub) => unsub());
|
||||
this._unsubUpdates = undefined;
|
||||
}
|
||||
@@ -291,12 +288,6 @@ export class LovelacePanel extends LitElement {
|
||||
this._fetchConfig(true);
|
||||
}
|
||||
|
||||
private _redirectToHome() {
|
||||
// Keeps the query and any open dialog: a more-info deep link may be
|
||||
// restored from the URL around this redirect.
|
||||
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
|
||||
}
|
||||
|
||||
private async _fetchConfig(forceDiskRefresh: boolean) {
|
||||
this._loading = true;
|
||||
|
||||
@@ -306,7 +297,7 @@ export class LovelacePanel extends LitElement {
|
||||
|
||||
// If no mode, redirect to /home as there is no "lovelace" dashboard
|
||||
if (!confMode) {
|
||||
this._redirectToHome();
|
||||
navigate("/home", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -324,21 +315,20 @@ export class LovelacePanel extends LitElement {
|
||||
(resources) => loadLovelaceResources(resources, this.hass!)
|
||||
);
|
||||
}
|
||||
// Refreshing a YAML config can trigger an update event. We will ignore
|
||||
// all update events while fetching the config and for 2 seconds after the config is back.
|
||||
// We ignore because we already have the latest config.
|
||||
if (confMode === "yaml") {
|
||||
this._ignoreNextUpdateEvent = true;
|
||||
}
|
||||
if (this.urlPath !== null || !confProm) {
|
||||
// Refreshing a YAML config can trigger an update event. We will ignore
|
||||
// all update events while fetching the config and for 2 seconds after the config is back.
|
||||
// We ignore because we already have the latest config.
|
||||
if (confMode === "yaml") {
|
||||
this._ignoreNextUpdateEvent = true;
|
||||
}
|
||||
|
||||
const fetchDashboardConfig = () =>
|
||||
fetchConfig(this.hass!.connection, this.urlPath, forceDiskRefresh);
|
||||
// The preload is the config of the "lovelace" dashboard. Fetch it again if
|
||||
// it failed, for example because the connection dropped before it came back.
|
||||
confProm =
|
||||
confProm && this.urlPath === "lovelace"
|
||||
? confProm.catch(fetchDashboardConfig)
|
||||
: fetchDashboardConfig();
|
||||
confProm = fetchConfig(
|
||||
this.hass!.connection,
|
||||
this.urlPath,
|
||||
forceDiskRefresh
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
rawConf = await confProm;
|
||||
@@ -367,7 +357,7 @@ export class LovelacePanel extends LitElement {
|
||||
const dashboards = await fetchDashboards(this.hass!);
|
||||
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
|
||||
if (!dashboard) {
|
||||
this._redirectToHome();
|
||||
navigate("/home", { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-118
@@ -32,7 +32,6 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
|
||||
import type { NavigateOptions } from "../../common/navigate";
|
||||
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
@@ -158,13 +157,6 @@ 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";
|
||||
@@ -591,7 +583,6 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
class=${classMap({
|
||||
"edit-mode": this._editMode,
|
||||
narrow: this.narrow,
|
||||
embedded: this.embedded,
|
||||
})}
|
||||
>
|
||||
<div class="header">
|
||||
@@ -621,23 +612,19 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
${
|
||||
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 || 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>
|
||||
`
|
||||
}
|
||||
${
|
||||
isSubview
|
||||
@@ -654,13 +641,9 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${
|
||||
this.embedded
|
||||
? nothing
|
||||
: html`<div class="action-items">
|
||||
${this._renderActionItems()}
|
||||
</div>`
|
||||
}
|
||||
<div class="action-items">
|
||||
${this._renderActionItems()}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -725,30 +708,17 @@ 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,
|
||||
});
|
||||
@@ -760,11 +730,6 @@ 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);
|
||||
@@ -832,12 +797,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
if (!viewPath && views.length) {
|
||||
newSelectView = views.findIndex(this._isVisible);
|
||||
// Filling in the default view keeps the entry as is: a dialog restored
|
||||
// from the URL, like a more-info deep link, can already be open.
|
||||
this._navigateToView(views[newSelectView].path || newSelectView, {
|
||||
replace: true,
|
||||
keepDialogs: true,
|
||||
});
|
||||
this._navigateToView(views[newSelectView].path || newSelectView, true);
|
||||
} else if (viewPath === "hass-unused-entities") {
|
||||
newSelectView = "hass-unused-entities";
|
||||
} else if (viewPath) {
|
||||
@@ -871,15 +831,14 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
viewPath === "hass-unused-entities"
|
||||
) {
|
||||
newSelectView = views.findIndex(this._isVisible);
|
||||
this._navigateToView(views[newSelectView].path || newSelectView, {
|
||||
replace: true,
|
||||
});
|
||||
this._navigateToView(
|
||||
views[newSelectView].path || newSelectView,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (huiView) {
|
||||
huiView.lovelace = this.lovelace!;
|
||||
}
|
||||
}
|
||||
@@ -887,7 +846,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
if (newSelectView !== undefined) {
|
||||
// Will allow for ripples to start rendering
|
||||
afterNextRender(() => {
|
||||
if (changedProperties.has("route") && !this.embedded) {
|
||||
if (changedProperties.has("route")) {
|
||||
const position =
|
||||
(this._restoreScroll && this._viewScrollPositions[newSelectView]) ||
|
||||
0;
|
||||
@@ -954,23 +913,6 @@ 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());
|
||||
}
|
||||
|
||||
@@ -1202,18 +1144,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _navigateToView(path: string | number, options?: NavigateOptions) {
|
||||
if (this.embedded) {
|
||||
fireEvent(this, "view-navigate", { path: String(path) });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private _navigateToView(path: string | number, replace?: boolean) {
|
||||
const url = this._viewUrl(path);
|
||||
|
||||
const currentUrl = `${location.pathname}${location.search}`;
|
||||
if (currentUrl !== url) {
|
||||
navigate(url, options);
|
||||
navigate(url, { replace });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,7 +1162,6 @@ 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;
|
||||
@@ -1283,7 +1218,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
newCurView = indexAfterMove(curView, oldIndex, newIndex);
|
||||
// Views without a path are addressed by their index in the URL
|
||||
if (newCurView !== curView && !this.config.views[curView].path) {
|
||||
this._navigateToView(newCurView, { replace: true });
|
||||
this._navigateToView(newCurView, true);
|
||||
}
|
||||
}
|
||||
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
|
||||
@@ -1347,7 +1282,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
// Save scroll position of current view
|
||||
if (this._curView != null && !this.embedded) {
|
||||
if (this._curView != null) {
|
||||
this._viewScrollPositions[this._curView] = window.scrollY;
|
||||
}
|
||||
|
||||
@@ -1688,19 +1623,6 @@ 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);
|
||||
@@ -1740,14 +1662,4 @@ 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,10 +1,7 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
|
||||
import {
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
} from "../../../../data/area/area_controls";
|
||||
import { getAreaControlEntities } 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";
|
||||
@@ -93,20 +90,15 @@ 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
|
||||
controls,
|
||||
area.area_id,
|
||||
hiddenEntities
|
||||
);
|
||||
|
||||
const filteredControls = controls.filter(
|
||||
|
||||
@@ -37,13 +37,11 @@ import type {
|
||||
UpdatesCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import { findMaintenanceEntities } from "../../../maintenance/strategies/maintenance-view-strategy";
|
||||
import {
|
||||
computeDefaultSecurityAlertVisibility,
|
||||
filterSecurityAlertEntities,
|
||||
resolveSecurityAlertSeverity,
|
||||
} from "../../../security/strategies/security-alerts";
|
||||
import { findSecurityEntities } from "../../../security/strategies/security-view-strategy";
|
||||
import {
|
||||
LARGE_SCREEN_CONDITION,
|
||||
SMALL_SCREEN_CONDITION,
|
||||
@@ -356,15 +354,40 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
)
|
||||
);
|
||||
|
||||
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
|
||||
(filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
const hasLights =
|
||||
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
|
||||
const hasMediaPlayers =
|
||||
findEntities(allEntities, mediaPlayerFilter).length > 0;
|
||||
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
|
||||
const hasSecurity =
|
||||
hass.panels.security && findSecurityEntities(hass).length > 0;
|
||||
hass.panels.security &&
|
||||
findEntities(allEntities, securityFilters).length > 0;
|
||||
const hasMaintenance =
|
||||
hass.panels.maintenance && findMaintenanceEntities(hass).length > 0;
|
||||
hass.panels.maintenance &&
|
||||
findEntities(allEntities, maintenanceFilters).length > 0;
|
||||
|
||||
const alertEntities = config.alert_entities ?? [];
|
||||
const alertSeverityEntities = filterSecurityAlertEntities(
|
||||
|
||||
@@ -43,11 +43,6 @@ 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,10 +79,6 @@ 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;
|
||||
@@ -146,8 +142,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
"section-visibility-changed",
|
||||
this._sectionVisibilityChanged
|
||||
);
|
||||
this._readSidebarTab = true;
|
||||
this.requestUpdate();
|
||||
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -159,12 +154,6 @@ 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();
|
||||
}
|
||||
@@ -502,14 +491,6 @@ 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;
|
||||
|
||||
@@ -3,10 +3,9 @@ import { customElement } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
import {
|
||||
findFilteredEntities,
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
type EntityFilter,
|
||||
type EntityFilterSources,
|
||||
} from "../../../common/entity/entity_filter";
|
||||
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -34,10 +33,6 @@ export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const findMaintenanceEntities = (
|
||||
sources: EntityFilterSources
|
||||
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
|
||||
|
||||
const LOW_BATTERY_THRESHOLD = 20;
|
||||
|
||||
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
|
||||
@@ -203,7 +198,20 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const entities = findMaintenanceEntities(hass);
|
||||
const allEntities = Object.keys(hass.states);
|
||||
|
||||
const batteryFilters = maintenanceEntityFilters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
const entities = findEntities(allEntities, batteryFilters);
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
|
||||
@@ -968,7 +968,7 @@ type: module</pre>`,
|
||||
? {
|
||||
icon: mdiAlertCircleOutline,
|
||||
text: localize(
|
||||
"ui.panel.marketplace.repository.community.open_issues_and_prs",
|
||||
"ui.panel.marketplace.repository.community.open_issues",
|
||||
{
|
||||
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_and_prs"
|
||||
"ui.panel.marketplace.repository.community.no_open_issues"
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -172,7 +172,6 @@ const entitiesTableParams = {
|
||||
floor: "string?",
|
||||
area: "string?",
|
||||
device: "string?",
|
||||
device_class: "string?",
|
||||
domain: "string?",
|
||||
entity_domain: "string?",
|
||||
label: "string?",
|
||||
|
||||
@@ -6,10 +6,9 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import {
|
||||
findFilteredEntities,
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
type EntityFilter,
|
||||
type EntityFilterSources,
|
||||
} from "../../../common/entity/entity_filter";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
|
||||
@@ -79,11 +78,11 @@ export const securityEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
|
||||
findFilteredEntities(sources, securityEntityFilters);
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: EntityFilterSources,
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): boolean =>
|
||||
securityEntityFilters.some((filter) =>
|
||||
@@ -183,7 +182,20 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const entities = findSecurityEntities(hass);
|
||||
const allEntities = Object.keys(hass.states);
|
||||
|
||||
const securityFilters = securityEntityFilters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
const entities = findEntities(allEntities, securityFilters);
|
||||
|
||||
const favoriteEntities = (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
|
||||
@@ -1050,8 +1050,7 @@
|
||||
"device_class": "Device class",
|
||||
"add": "Add device class",
|
||||
"no_device_classes": "No device classes available",
|
||||
"no_match": "No device classes found for {term}",
|
||||
"unit_not_supported": "Does not support {unit}"
|
||||
"no_match": "No device classes found for {term}"
|
||||
},
|
||||
"category-picker": {
|
||||
"clear": "Clear",
|
||||
@@ -2861,15 +2860,13 @@
|
||||
},
|
||||
"panel": {
|
||||
"app": {
|
||||
"error_loading_app": "Unable to load the app information from the Supervisor: {error}",
|
||||
"error_app_unauthorized": "You do not have permission to open this app.",
|
||||
"error_app_not_installed": "The app is not installed. Please install it first.",
|
||||
"error_app_no_ingress": "This app does not support ingress.",
|
||||
"error_app_not_running": "The app is not running. Do you want to start it now?",
|
||||
"start_app": "Start app",
|
||||
"app_starting": "The app is starting, this can take some time...",
|
||||
"error_starting_app": "Error starting the app",
|
||||
"error_creating_session": "Unable to create an ingress session: {error}",
|
||||
"error_creating_session": "Unable to create an ingress session",
|
||||
"error_app_not_ready": "The app seems to not be ready, it might still be starting. Do you want to try again?",
|
||||
"retry": "Retry"
|
||||
},
|
||||
@@ -7637,7 +7634,6 @@
|
||||
"state_icon": "State icon",
|
||||
"entity": "Entity",
|
||||
"device": "Device",
|
||||
"device_class": "Device class",
|
||||
"integration": "Integration",
|
||||
"disabled_by": "Disabled by",
|
||||
"status": "Status",
|
||||
@@ -7930,10 +7926,8 @@
|
||||
"no_config_flow": "This integration was not set up from the UI",
|
||||
"disabled_polling": "Automatic polling for updated data disabled",
|
||||
"debug_logging_enabled": "Debug logging enabled",
|
||||
"debug_logging_enabled_description": "Extra detail from this integration is being written to the logs. Reproduce the problem, then disable it to download the log for your issue report. Leaving it on can slow Home Assistant down and fill up the logs.",
|
||||
"debug_logging_enabled_description": "Extra detail from this integration is being written to the logs. Reproduce the problem, then disable it. Home Assistant will download the log for your issue report if log file downloads are available. Leaving it on can slow Home Assistant down and fill up the logs.",
|
||||
"show_logs": "Show logs",
|
||||
"debug_log_ready": "Debug log ready",
|
||||
"debug_log_ready_description": "Debug logging is disabled. Download the log to attach it to your issue report.",
|
||||
"live_log": {
|
||||
"title": "Live log",
|
||||
"waiting": "Waiting for log messages from {integration}…",
|
||||
@@ -7974,7 +7968,6 @@
|
||||
"error_saving_device": "Error updating device: {error}",
|
||||
"error_saving_entity": "Error updating entity: {error}",
|
||||
"created_config": "Created configuration for {name}.",
|
||||
"helper_area": "This helper isn't part of a device, so you can add it to an area.",
|
||||
"external_step": {
|
||||
"description": "This step requires you to visit an external website to be completed.",
|
||||
"open_site": "Open website"
|
||||
@@ -12811,7 +12804,6 @@
|
||||
"location_name": "Name of your Home Assistant installation",
|
||||
"location_name_default": "Home",
|
||||
"address_label": "Search address",
|
||||
"search_results": "Search results",
|
||||
"button_detect": "Detect",
|
||||
"finish": "Next"
|
||||
},
|
||||
@@ -13201,8 +13193,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_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"
|
||||
"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"
|
||||
},
|
||||
"details": {
|
||||
"requires": "Requires Home Assistant {version} or newer",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findFilteredEntities,
|
||||
generateEntityFilter,
|
||||
} from "../../../src/common/entity/entity_filter";
|
||||
import { generateEntityFilter } from "../../../src/common/entity/entity_filter";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
// Mock HomeAssistant with comprehensive data
|
||||
@@ -708,17 +705,3 @@ describe("generateEntityFilter", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findFilteredEntities", () => {
|
||||
it("should match entities against any of the filters", () => {
|
||||
const entities = findFilteredEntities(mockHass, [
|
||||
{ domain: "light" },
|
||||
{ domain: "switch" },
|
||||
]);
|
||||
|
||||
expect(entities).toContain("light.living_room");
|
||||
expect(entities).toContain("light.bedroom");
|
||||
expect(entities).toContain("switch.kitchen");
|
||||
expect(entities).not.toContain("sensor.temperature");
|
||||
});
|
||||
});
|
||||
@@ -12,15 +12,10 @@ import {
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
|
||||
|
||||
// navigate() closes open dialogs before touching history. A closed dialog
|
||||
// drops its state from the entry, which is what navigate() waits for.
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => {
|
||||
window.history.replaceState(null, "");
|
||||
return true;
|
||||
}),
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// Fabricates a raw entry like a document load, which navigate() never produces.
|
||||
@@ -61,7 +56,6 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
|
||||
|
||||
afterEach(() => {
|
||||
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
|
||||
vi.mocked(closeAllDialogs).mockClear();
|
||||
});
|
||||
|
||||
describe("navigate", () => {
|
||||
@@ -103,41 +97,6 @@ describe("navigate", () => {
|
||||
expect(window.history.state).toBeNull();
|
||||
expect(canGoBack()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes an open dialog before replacing the entry", async () => {
|
||||
setEntry("/home", { dialog: "ha-more-info-dialog" });
|
||||
|
||||
await navigate("/home/overview", { replace: true });
|
||||
|
||||
expect(closeAllDialogs).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/home/overview");
|
||||
expect(window.history.state).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an open dialog and the entry state when asked to", async () => {
|
||||
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
|
||||
|
||||
await navigate("/home/overview", { replace: true, keepDialogs: true });
|
||||
|
||||
expect(closeAllDialogs).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toEqual("/home/overview");
|
||||
expect(window.history.state).toEqual({
|
||||
dialog: "ha-more-info-dialog",
|
||||
from: "/",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not drop a navigation right after one that keeps dialogs", async () => {
|
||||
setEntry("/home");
|
||||
|
||||
const results = await Promise.all([
|
||||
navigate("/home/overview", { replace: true, keepDialogs: true }),
|
||||
navigate("/config"),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([true, true]);
|
||||
expect(window.location.pathname).toEqual("/config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsaved changes guard", () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("entities query params", () => {
|
||||
"ha-filter-states": [],
|
||||
"ha-filter-floor-areas": undefined,
|
||||
"ha-filter-devices": ["abc"],
|
||||
"ha-filter-device-classes": [],
|
||||
"ha-filter-integrations": [],
|
||||
"ha-filter-domains": [],
|
||||
"ha-filter-labels": ["a", "b"],
|
||||
@@ -64,7 +63,6 @@ describe("entities query params", () => {
|
||||
"ha-filter-states": ["enabled", "hidden"],
|
||||
"ha-filter-floor-areas": { floors: ["ground"], areas: ["kitchen"] },
|
||||
"ha-filter-devices": ["abc"],
|
||||
"ha-filter-device-classes": ["problem", "temperature"],
|
||||
"ha-filter-integrations": ["hue"],
|
||||
"ha-filter-domains": ["light"],
|
||||
"ha-filter-labels": ["a"],
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../../src/data/device_classes";
|
||||
import {
|
||||
computeAllowedUnits,
|
||||
computeDeviceClassForUnit,
|
||||
computeSelectorUnits,
|
||||
computeUnitDeviceClasses,
|
||||
} from "../../src/data/sensor/unit_of_measurement";
|
||||
import { SENSOR_DEVICE_CLASS_UNITS } from "../../src/data/sensor_entity_constants";
|
||||
|
||||
const sorted = (units?: (string | null)[]) =>
|
||||
units === undefined ? undefined : [...units].sort();
|
||||
@@ -168,90 +164,3 @@ describe("computeSelectorUnits", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeUnitDeviceClasses", () => {
|
||||
it("does not filter without a unit", () => {
|
||||
expect(computeUnitDeviceClasses("sensor", undefined)).toBeUndefined();
|
||||
expect(computeUnitDeviceClasses("sensor", null)).toBeUndefined();
|
||||
expect(computeUnitDeviceClasses("sensor", "")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not filter domains without units", () => {
|
||||
expect(computeUnitDeviceClasses("binary_sensor", "lx")).toBeUndefined();
|
||||
expect(computeUnitDeviceClasses(undefined, "lx")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds the only device class of a unit", () => {
|
||||
expect(computeUnitDeviceClasses("sensor", "lx")).toEqual({
|
||||
matching: ["illuminance"],
|
||||
unrestricted: ["monetary"],
|
||||
});
|
||||
});
|
||||
|
||||
it("finds all device classes of a unit", () => {
|
||||
expect(computeUnitDeviceClasses("sensor", "°C")?.matching.sort()).toEqual([
|
||||
"temperature",
|
||||
"temperature_delta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only accepts device classes without a unit table for a custom unit", () => {
|
||||
expect(computeUnitDeviceClasses("sensor", "beans")).toEqual({
|
||||
matching: [],
|
||||
unrestricted: ["monetary"],
|
||||
});
|
||||
});
|
||||
|
||||
it("never accepts a unit for unitless and non-numeric device classes", () => {
|
||||
const result = computeUnitDeviceClasses("sensor", "%")!;
|
||||
for (const deviceClass of ["aqi", "ph", "enum", "date", "timestamp"]) {
|
||||
expect(result.matching).not.toContain(deviceClass);
|
||||
expect(result.unrestricted).not.toContain(deviceClass);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the sensor unit tables for numbers", () => {
|
||||
expect(computeUnitDeviceClasses("number", "lx")).toEqual({
|
||||
matching: ["illuminance"],
|
||||
unrestricted: ["monetary"],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches the units each device class allows", () => {
|
||||
const units = new Set(
|
||||
Object.values(SENSOR_DEVICE_CLASS_UNITS)
|
||||
.flat()
|
||||
.filter((unit): unit is string => unit !== null)
|
||||
);
|
||||
for (const unit of units) {
|
||||
const { matching } = computeUnitDeviceClasses("sensor", unit)!;
|
||||
for (const deviceClass of DOMAIN_DEVICE_CLASSES.sensor) {
|
||||
const allowed = computeAllowedUnits([deviceClass]);
|
||||
expect(matching.includes(deviceClass), `${unit} ${deviceClass}`).toBe(
|
||||
allowed?.includes(unit) ?? false
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDeviceClassForUnit", () => {
|
||||
it("preselects the only device class of a unit", () => {
|
||||
expect(computeDeviceClassForUnit("sensor", "lx")).toBe("illuminance");
|
||||
expect(computeDeviceClassForUnit("number", "lx")).toBe("illuminance");
|
||||
});
|
||||
|
||||
it("does not preselect when several device classes fit", () => {
|
||||
expect(computeDeviceClassForUnit("sensor", "°C")).toBeUndefined();
|
||||
expect(computeDeviceClassForUnit("sensor", "%")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not preselect for a custom unit", () => {
|
||||
expect(computeDeviceClassForUnit("sensor", "beans")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not preselect without a unit or for domains without units", () => {
|
||||
expect(computeDeviceClassForUnit("sensor", undefined)).toBeUndefined();
|
||||
expect(computeDeviceClassForUnit("binary_sensor", "lx")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HaDialog } from "../../../src/components/ha-dialog";
|
||||
import type { ConfigEntry } from "../../../src/data/config_entries";
|
||||
import type {
|
||||
DataEntryFlowProgressedEvent,
|
||||
DataEntryFlowStep,
|
||||
DataEntryFlowStepCreateEntry,
|
||||
DataEntryFlowStepExternal,
|
||||
DataEntryFlowStepForm,
|
||||
DataEntryFlowStepMenu,
|
||||
DataEntryFlowStepProgress,
|
||||
} from "../../../src/data/data_entry_flow";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import "../../../src/dialogs/config-flow/dialog-data-entry-flow";
|
||||
import type { FlowConfig } from "../../../src/dialogs/config-flow/show-dialog-data-entry-flow";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -56,16 +53,6 @@ vi.mock("../../../src/components/ha-list-item", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-area-picker", () => {
|
||||
customElements.define("ha-area-picker", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/input/ha-input", () => {
|
||||
customElements.define("ha-input", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
const formStep: DataEntryFlowStepForm = {
|
||||
type: "form",
|
||||
flow_id: "test-flow",
|
||||
@@ -144,7 +131,6 @@ const eventListeners: Record<string, (ev: unknown) => void> = {};
|
||||
const hass = {
|
||||
localize: (key: string) => key,
|
||||
devices: {},
|
||||
callWS: vi.fn(),
|
||||
connection: {
|
||||
subscribeEvents: vi.fn(
|
||||
async (callback: (ev: unknown) => void, eventType: string) => {
|
||||
@@ -412,83 +398,3 @@ describe("dialog-data-entry-flow progress", () => {
|
||||
expect(dialog["_step"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialog-data-entry-flow finish", () => {
|
||||
const createEntryStep: DataEntryFlowStepCreateEntry = {
|
||||
type: "create_entry",
|
||||
flow_id: "test-flow",
|
||||
handler: "test",
|
||||
title: "Test",
|
||||
result: {
|
||||
entry_id: "test-entry",
|
||||
domain: "test",
|
||||
title: "Test",
|
||||
state: "loaded",
|
||||
} as ConfigEntry,
|
||||
description: null,
|
||||
};
|
||||
|
||||
const device = {
|
||||
id: "device-1",
|
||||
name: "Lamp",
|
||||
name_by_user: null,
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
area_id: null,
|
||||
primary_config_entry: null,
|
||||
config_entries: ["test-entry"],
|
||||
} as DeviceRegistryEntry;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("saves the device updates once when Finish is clicked twice", async () => {
|
||||
const pendingSaves: (() => void)[] = [];
|
||||
vi.mocked(hass.callWS).mockImplementation((async (msg: {
|
||||
type: string;
|
||||
}) => {
|
||||
if (msg.type === "config/device_registry/update") {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
pendingSaves.push(() => resolve({}));
|
||||
});
|
||||
}
|
||||
if (msg.type === "config/entity_registry/get_automatic_entity_ids") {
|
||||
return {};
|
||||
}
|
||||
return [];
|
||||
}) as HomeAssistant["callWS"]);
|
||||
const step = document.createElement("step-flow-create-entry");
|
||||
step.hass = { ...hass, entities: {} };
|
||||
step.flowConfig = flowConfig;
|
||||
step.step = createEntryStep;
|
||||
step.devices = [device];
|
||||
document.body.append(step);
|
||||
await step.updateComplete;
|
||||
step
|
||||
.shadowRoot!.querySelector("ha-area-picker")!
|
||||
.dispatchEvent(
|
||||
new CustomEvent("value-changed", { detail: { value: "kitchen" } })
|
||||
);
|
||||
await step.updateComplete;
|
||||
const flowUpdates: unknown[] = [];
|
||||
step.addEventListener("flow-update", (ev) => {
|
||||
flowUpdates.push(ev.detail);
|
||||
});
|
||||
|
||||
const first = step.finish();
|
||||
const second = step.finish();
|
||||
pendingSaves.forEach((save) => save());
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(hass.callWS)
|
||||
.mock.calls.filter(
|
||||
([msg]) => msg.type === "config/device_registry/update"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(flowUpdates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -371,9 +371,7 @@ export async function searchAddress(page: Page) {
|
||||
const input = location.locator("ha-input input");
|
||||
await input.fill(place.name);
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
location.getByRole("list", { name: "Search results" })
|
||||
).toContainText(place.name);
|
||||
await expect(location.locator("ha-list")).toContainText(place.name);
|
||||
}
|
||||
|
||||
export async function confirmLocation(page: Page) {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
|
||||
// The Marketplace loads its translations for the path it is at
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
"/marketplace/repository/1?category=theme#top",
|
||||
{ replace: true, keepDialogs: true }
|
||||
{ replace: true }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { subscribeLovelaceUpdates } from "../../../src/data/lovelace";
|
||||
import { fetchConfig } from "../../../src/data/lovelace/config/types";
|
||||
import "../../../src/panels/lovelace/ha-panel-lovelace";
|
||||
|
||||
type LovelacePanel = HTMLElement & {
|
||||
hass: unknown;
|
||||
panel: unknown;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
vi.mock("../../../src/panels/lovelace/hui-root", () => ({}));
|
||||
vi.mock("../../../src/layouts/hass-loading-screen", () => ({}));
|
||||
vi.mock("../../../src/layouts/hass-error-screen", () => ({}));
|
||||
vi.mock("../../../src/data/lovelace", () => ({
|
||||
subscribeLovelaceUpdates: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../src/data/lovelace/config/types", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
fetchConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../src/data/lovelace/resource", () => ({
|
||||
fetchResources: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("../../../src/panels/lovelace/common/load-resources", () => ({
|
||||
loadLovelaceResources: vi.fn(),
|
||||
}));
|
||||
|
||||
const flush = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
|
||||
const openPanel = async (urlPath: string) => {
|
||||
let notifyUpdated: () => void = () => undefined;
|
||||
const unsub = vi.fn(async () => {
|
||||
notifyUpdated = () => undefined;
|
||||
});
|
||||
vi.mocked(subscribeLovelaceUpdates).mockImplementation(
|
||||
(_conn, _urlPath, onChange) => {
|
||||
notifyUpdated = onChange;
|
||||
return Promise.resolve(unsub);
|
||||
}
|
||||
);
|
||||
vi.mocked(fetchConfig).mockResolvedValue({ views: [{ cards: [] }] });
|
||||
|
||||
const panel = document.createElement(
|
||||
"ha-panel-lovelace"
|
||||
) as unknown as LovelacePanel;
|
||||
panel.hass = {
|
||||
connection: {},
|
||||
locale: { language: "en" },
|
||||
localize: (key: string) => key,
|
||||
};
|
||||
panel.panel = {
|
||||
url_path: urlPath,
|
||||
component_name: "lovelace",
|
||||
config: { mode: "storage" },
|
||||
};
|
||||
document.body.append(panel);
|
||||
await panel.updateComplete;
|
||||
await flush();
|
||||
return { panel, unsub, notifyUpdated: () => notifyUpdated() };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ha-panel-lovelace config updates", () => {
|
||||
it("refetches the cached lovelace dashboard when it was updated while away", async () => {
|
||||
const { panel, unsub, notifyUpdated } = await openPanel("lovelace");
|
||||
expect(fetchConfig).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Navigate away: the panel resolver keeps this element cached.
|
||||
panel.remove();
|
||||
await flush();
|
||||
expect(unsub).not.toHaveBeenCalled();
|
||||
|
||||
// Config saved from elsewhere, e.g. "Add to dashboard" on a device page.
|
||||
notifyUpdated();
|
||||
expect(fetchConfig).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.append(panel);
|
||||
await flush();
|
||||
expect(fetchConfig).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Coming back again without a change uses the cached config.
|
||||
panel.remove();
|
||||
document.body.append(panel);
|
||||
await flush();
|
||||
expect(fetchConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("unsubscribes other dashboards when they are removed", async () => {
|
||||
const { panel, unsub } = await openPanel("dashboard-test");
|
||||
panel.remove();
|
||||
await flush();
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -673,7 +673,7 @@ describe("ha-marketplace-repository-dashboard", () => {
|
||||
)
|
||||
).toEqual([
|
||||
"ui.panel.marketplace.repository.community.stars",
|
||||
"ui.panel.marketplace.repository.community.open_issues_and_prs",
|
||||
"ui.panel.marketplace.repository.community.open_issues",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ describe("Marketplace strings", () => {
|
||||
"1 star on GitHub",
|
||||
],
|
||||
[
|
||||
"repository.community.open_issues_and_prs",
|
||||
"repository.community.open_issues",
|
||||
{ number: 1, count: "1" },
|
||||
"1 open issue or pull request on GitHub",
|
||||
"1 open issue on GitHub",
|
||||
],
|
||||
])("formats %s with %o", (key, values, expected) => {
|
||||
expect(format(key, values)).toBe(expected);
|
||||
|
||||
Reference in new issue
Block a user