mirror of
https://github.com/home-assistant/frontend.git
synced 2026-01-14 11:17:26 +00:00
Compare commits
20 Commits
quick-bar-
...
fix-issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24ef59718 | ||
|
|
50be1d9345 | ||
|
|
c551bf03b6 | ||
|
|
cd062293fc | ||
|
|
e89ea47d3a | ||
|
|
2cd209a6a4 | ||
|
|
9bbc761736 | ||
|
|
9097faa04b | ||
|
|
fcf844cf1a | ||
|
|
8808c31e98 | ||
|
|
e0a9f5a08a | ||
|
|
56d71c8e54 | ||
|
|
125ab4c671 | ||
|
|
8014216c45 | ||
|
|
55ba331489 | ||
|
|
ad2ff672b0 | ||
|
|
00907ecd17 | ||
|
|
07d8219136 | ||
|
|
f37241c84c | ||
|
|
65d046132d |
@@ -213,7 +213,9 @@ const createRspackConfig = ({
|
||||
"lit/directives/join$": "lit/directives/join.js",
|
||||
"lit/directives/repeat$": "lit/directives/repeat.js",
|
||||
"lit/directives/live$": "lit/directives/live.js",
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"lit/directives/keyed$": latestBuild
|
||||
? "lit/directives/keyed.js"
|
||||
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
|
||||
"lit/polyfill-support$": "lit/polyfill-support.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
|
||||
@@ -93,8 +93,8 @@ export const calcDateRange = (
|
||||
];
|
||||
case "now-12m":
|
||||
return [
|
||||
calcDate(subMonths(today, 12), startOfMonth, hass.locale, hass.config),
|
||||
calcDate(subMonths(today, 1), endOfMonth, hass.locale, hass.config),
|
||||
calcDate(today, subMonths, hass.locale, hass.config, 12),
|
||||
calcDate(today, subMonths, hass.locale, hass.config, 0),
|
||||
];
|
||||
case "now-1h":
|
||||
return [
|
||||
|
||||
53
src/common/lit/keyed-es5.ts
Normal file
53
src/common/lit/keyed-es5.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* ES5-compatible implementation of the keyed directive.
|
||||
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
|
||||
*
|
||||
* This implementation avoids parameter destructuring in the update() method,
|
||||
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
|
||||
*
|
||||
* Used only for ES5 builds (legacy browsers). Modern builds use the original
|
||||
* lit-html keyed directive.
|
||||
*
|
||||
* @see https://github.com/home-assistant/frontend/issues/28732
|
||||
*/
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { directive, Directive } from "lit-html/directive.js";
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { setCommittedValue } from "lit-html/directive-helpers.js";
|
||||
// eslint-disable-next-line lit/no-legacy-imports
|
||||
import { nothing } from "lit-html";
|
||||
// eslint-disable-next-line import/extensions
|
||||
import type { Part } from "lit-html/directive.js";
|
||||
|
||||
class KeyedES5 extends Directive {
|
||||
private _key: unknown = nothing;
|
||||
|
||||
render(k: unknown, v: unknown) {
|
||||
this._key = k;
|
||||
return v;
|
||||
}
|
||||
|
||||
update(part: unknown, args: [unknown, unknown]) {
|
||||
const k = args[0];
|
||||
const v = args[1];
|
||||
if (k !== this._key) {
|
||||
// Clear the part before returning a value. The one-arg form of
|
||||
// setCommittedValue sets the value to a sentinel which forces a
|
||||
// commit the next render.
|
||||
setCommittedValue(part as Part);
|
||||
this._key = k;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a renderable value with a unique key. When the key changes, the
|
||||
* previous DOM is removed and disposed before rendering the next value, even
|
||||
* if the value - such as a template - is the same.
|
||||
*
|
||||
* This is useful for forcing re-renders of stateful components, or working
|
||||
* with code that expects new data to generate new HTML elements, such as some
|
||||
* animation techniques.
|
||||
*/
|
||||
export const keyed = directive(KeyedES5);
|
||||
@@ -21,6 +21,7 @@ import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
const parsed = parseFloat(value);
|
||||
@@ -184,7 +185,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
|
||||
if (param.seriesName) {
|
||||
return `${param.marker} ${param.seriesName}: ${value}`;
|
||||
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
|
||||
}
|
||||
return `${param.marker} ${value}`;
|
||||
})
|
||||
|
||||
@@ -143,17 +143,19 @@ export class HaEntityToggle extends LitElement {
|
||||
// Optimistic update.
|
||||
this._isOn = turnOn;
|
||||
|
||||
await this.hass.callService(serviceDomain, service, {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
// If after 2 seconds we have not received a state update
|
||||
// reset the switch to it's original state.
|
||||
if (this.stateObj === currentState) {
|
||||
this._isOn = isOn(this.stateObj);
|
||||
}
|
||||
}, 2000);
|
||||
try {
|
||||
await this.hass.callService(serviceDomain, service, {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
} finally {
|
||||
setTimeout(async () => {
|
||||
// If after 2 seconds we have not received a state update
|
||||
// reset the switch to it's original state.
|
||||
if (this.stateObj === currentState) {
|
||||
this._isOn = isOn(this.stateObj);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -51,7 +51,10 @@ export class HaCard extends LitElement {
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
|
||||
:host ::slotted(.card-content:not(:first-child)),
|
||||
:host
|
||||
::slotted(
|
||||
.card-content:not(:nth-child(1 of .card-content, .card-header))
|
||||
),
|
||||
slot:not(:first-child)::slotted(.card-content) {
|
||||
padding-top: 0;
|
||||
margin-top: calc(var(--ha-space-2) * -1);
|
||||
|
||||
@@ -50,7 +50,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
|
||||
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
|
||||
* @cssprop --ha-dialog-surface-background - Dialog background color.
|
||||
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
|
||||
* @cssprop --dialog-z-index - Z-index for the dialog.
|
||||
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
|
||||
*
|
||||
* @attr {boolean} open - Controls the dialog open state.
|
||||
|
||||
@@ -24,6 +24,7 @@ import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import type { HomeAssistant, ThemeMode } from "../../types";
|
||||
import { isTouch } from "../../util/is_touch";
|
||||
import "../ha-icon-button";
|
||||
@@ -381,7 +382,7 @@ export class HaMap extends ReactiveElement {
|
||||
this.hass.config
|
||||
);
|
||||
}
|
||||
return `${path.name}<br>${formattedTime}`;
|
||||
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
|
||||
}
|
||||
|
||||
private _drawPaths(): void {
|
||||
@@ -549,7 +550,7 @@ export class HaMap extends ReactiveElement {
|
||||
iconHTML = el.outerHTML;
|
||||
} else {
|
||||
const el = document.createElement("span");
|
||||
el.innerHTML = title;
|
||||
el.textContent = title;
|
||||
iconHTML = el.outerHTML;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
} from "../../data/media-player";
|
||||
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-dialog";
|
||||
import "../ha-wa-dialog";
|
||||
import "../ha-dialog-header";
|
||||
import "../ha-list-item";
|
||||
import "../ha-icon-button-arrow-prev";
|
||||
@@ -44,6 +44,8 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
|
||||
@state() _preferredLayout: MediaPlayerLayoutType = "auto";
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse;
|
||||
|
||||
public showDialog(params: MediaPlayerBrowseDialogParams): void {
|
||||
@@ -54,9 +56,11 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
media_content_type: undefined,
|
||||
},
|
||||
];
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._navigateIds = undefined;
|
||||
this._currentItem = undefined;
|
||||
@@ -71,21 +75,14 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
scrimClickAction
|
||||
escapeKeyAction
|
||||
hideActions
|
||||
flexContent
|
||||
.heading=${!this._currentItem
|
||||
? this.hass.localize(
|
||||
"ui.components.media-browser.media-player-browser"
|
||||
)
|
||||
: this._currentItem.title}
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
flexcontent
|
||||
@closed=${this.closeDialog}
|
||||
@opened=${this._dialogOpened}
|
||||
>
|
||||
<ha-dialog-header show-border slot="heading">
|
||||
<ha-dialog-header show-border slot="header">
|
||||
${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1)
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@@ -152,7 +149,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
dialogAction="close"
|
||||
data-dialog="close"
|
||||
slot="actionItems"
|
||||
></ha-icon-button>
|
||||
</ha-dialog-header>
|
||||
@@ -172,7 +169,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
@media-picked=${this._mediaPicked}
|
||||
@media-browsed=${this._mediaBrowsed}
|
||||
></ha-media-player-browse>
|
||||
</ha-dialog>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -224,8 +221,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-z-index: 9;
|
||||
ha-wa-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@@ -240,9 +236,9 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 800px;
|
||||
--mdc-dialog-max-height: calc(
|
||||
ha-wa-dialog {
|
||||
--ha-dialog-max-width: 800px;
|
||||
--ha-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { type CSSResultGroup, LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { mdiSpeaker } from "@mdi/js";
|
||||
import { mdiSpeaker, mdiSpeakerPause, mdiSpeakerPlay } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
import "../ha-switch";
|
||||
import "../ha-svg-icon";
|
||||
import type { MediaPlayerEntity } from "../../data/media-player";
|
||||
|
||||
@customElement("ha-media-player-toggle")
|
||||
class HaMediaPlayerToggle extends LitElement {
|
||||
@@ -20,15 +21,61 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
private _computeDisplayData = memoizeOne(
|
||||
(
|
||||
entityId: string,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
isRTL: boolean,
|
||||
stateObj: HomeAssistant["states"][string]
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const stateObj = this.hass.states[this.entityId];
|
||||
|
||||
let icon = mdiSpeaker;
|
||||
if (stateObj.state === "playing") {
|
||||
icon = mdiSpeakerPlay;
|
||||
} else if (stateObj.state === "paused") {
|
||||
icon = mdiSpeakerPause;
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
const { primary, secondary } = this._computeDisplayData(
|
||||
this.entityId,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors,
|
||||
isRTL,
|
||||
stateObj
|
||||
);
|
||||
|
||||
return html`<div class="list-item">
|
||||
<ha-svg-icon .path=${mdiSpeaker}></ha-svg-icon>
|
||||
<ha-svg-icon .path=${icon}></ha-svg-icon>
|
||||
<div class="info">
|
||||
<div class="main-text">${computeStateName(stateObj)}</div>
|
||||
<div class="secondary-text">
|
||||
${this._formatSecondaryText(stateObj as MediaPlayerEntity)}
|
||||
</div>
|
||||
<div class="main-text">${primary}</div>
|
||||
<div class="secondary-text">${secondary}</div>
|
||||
</div>
|
||||
<ha-switch
|
||||
.disabled=${this.disabled}
|
||||
@@ -38,16 +85,6 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _formatSecondaryText(stateObj: MediaPlayerEntity): string {
|
||||
if (stateObj.state !== "playing") {
|
||||
return this.hass.localize("ui.card.media_player.idle");
|
||||
}
|
||||
|
||||
return [stateObj.attributes.media_title, stateObj.attributes.media_artist]
|
||||
.filter((segment) => segment)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface BaseActionConfig {
|
||||
|
||||
export interface ConfirmationRestrictionConfig {
|
||||
text?: string;
|
||||
title?: string;
|
||||
confirm_text?: string;
|
||||
dismiss_text?: string;
|
||||
exemptions?: RestrictionConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,14 +44,27 @@ export const updateUsesProgress = (entity: UpdateEntity): boolean =>
|
||||
supportsFeature(entity, UpdateEntityFeature.PROGRESS) &&
|
||||
entity.attributes.update_percentage !== null;
|
||||
|
||||
export const updateAvailable = (
|
||||
entity: UpdateEntity,
|
||||
showSkipped = false
|
||||
): boolean =>
|
||||
entity.state === BINARY_STATE_ON ||
|
||||
(showSkipped && Boolean(entity.attributes.skipped_version));
|
||||
|
||||
export const updateCanInstall = (
|
||||
entity: UpdateEntity,
|
||||
showSkipped = false
|
||||
): boolean =>
|
||||
(entity.state === BINARY_STATE_ON ||
|
||||
(showSkipped && Boolean(entity.attributes.skipped_version))) &&
|
||||
updateAvailable(entity, showSkipped) &&
|
||||
supportsFeature(entity, UpdateEntityFeature.INSTALL);
|
||||
|
||||
export const updateCanNotInstall = (
|
||||
entity: UpdateEntity,
|
||||
showSkipped = false
|
||||
): boolean =>
|
||||
updateAvailable(entity, showSkipped) &&
|
||||
!supportsFeature(entity, UpdateEntityFeature.INSTALL);
|
||||
|
||||
export const latestVersionIsSkipped = (entity: UpdateEntity): boolean =>
|
||||
!!(
|
||||
entity.attributes.latest_version &&
|
||||
@@ -108,13 +121,17 @@ export const filterUpdateEntities = (
|
||||
);
|
||||
});
|
||||
|
||||
export const filterUpdateEntitiesWithInstall = (
|
||||
export const filterUpdateEntitiesParameterized = (
|
||||
entities: HassEntities,
|
||||
showSkipped = false
|
||||
showSkipped = false,
|
||||
showNotInstallable = false
|
||||
) =>
|
||||
filterUpdateEntities(entities).filter((entity) =>
|
||||
updateCanInstall(entity, showSkipped)
|
||||
);
|
||||
filterUpdateEntities(entities).filter((entity) => {
|
||||
if (showNotInstallable) {
|
||||
return updateCanNotInstall(entity, showSkipped);
|
||||
}
|
||||
return updateCanInstall(entity, showSkipped);
|
||||
});
|
||||
|
||||
export const checkForEntityUpdates = async (
|
||||
element: HTMLElement,
|
||||
|
||||
@@ -152,7 +152,7 @@ export const provideHass = (
|
||||
for (const ent of ensureArray(newEntities)) {
|
||||
hass().entities[ent.entityId] = {
|
||||
entity_id: ent.entityId,
|
||||
name: ent.name,
|
||||
name: ent.attributes.friendly_name || null,
|
||||
icon: ent.icon,
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
|
||||
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "../../../data/hassio/supervisor";
|
||||
import {
|
||||
checkForEntityUpdates,
|
||||
filterUpdateEntitiesWithInstall,
|
||||
filterUpdateEntitiesParameterized,
|
||||
} from "../../../data/update";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -53,7 +53,11 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const canInstallUpdates = this._filterUpdateEntitiesWithInstall(
|
||||
const canInstallUpdates = this._filterInstallableUpdateEntities(
|
||||
this.hass.states,
|
||||
this._showSkipped
|
||||
);
|
||||
const notInstallableUpdates = this._filterNotInstallableUpdateEntities(
|
||||
this.hass.states,
|
||||
this._showSkipped
|
||||
);
|
||||
@@ -100,30 +104,49 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
)}
|
||||
</ha-list-item>
|
||||
`
|
||||
: ""}
|
||||
: nothing}
|
||||
</ha-button-menu>
|
||||
</div>
|
||||
<div class="content">
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
${canInstallUpdates.length
|
||||
? html`
|
||||
${canInstallUpdates.length
|
||||
? html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
<ha-config-updates
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.updateEntities=${canInstallUpdates}
|
||||
.isInstallable=${true}
|
||||
showAll
|
||||
></ha-config-updates>
|
||||
`
|
||||
: html`
|
||||
<div class="no-updates">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.updates.no_updates"
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
: nothing}
|
||||
${notInstallableUpdates.length
|
||||
? html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
<ha-config-updates
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.updateEntities=${notInstallableUpdates}
|
||||
.isInstallable=${false}
|
||||
showAll
|
||||
></ha-config-updates>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
: nothing}
|
||||
${canInstallUpdates.length + notInstallableUpdates.length
|
||||
? nothing
|
||||
: html`
|
||||
<ha-card outlined>
|
||||
<div class="no-updates">
|
||||
${this.hass.localize("ui.panel.config.updates.no_updates")}
|
||||
</div>
|
||||
</ha-card>
|
||||
`}
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -177,9 +200,14 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
checkForEntityUpdates(this, this.hass);
|
||||
}
|
||||
|
||||
private _filterUpdateEntitiesWithInstall = memoizeOne(
|
||||
private _filterInstallableUpdateEntities = memoizeOne(
|
||||
(entities: HassEntities, showSkipped: boolean) =>
|
||||
filterUpdateEntitiesWithInstall(entities, showSkipped)
|
||||
filterUpdateEntitiesParameterized(entities, showSkipped, false)
|
||||
);
|
||||
|
||||
private _filterNotInstallableUpdateEntities = memoizeOne(
|
||||
(entities: HassEntities, showSkipped: boolean) =>
|
||||
filterUpdateEntitiesParameterized(entities, showSkipped, true)
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import type { UpdateEntity } from "../../../data/update";
|
||||
import {
|
||||
checkForEntityUpdates,
|
||||
filterUpdateEntitiesWithInstall,
|
||||
filterUpdateEntitiesParameterized,
|
||||
} from "../../../data/update";
|
||||
import {
|
||||
QuickBarMode,
|
||||
@@ -206,7 +206,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const { updates: canInstallUpdates, total: totalUpdates } =
|
||||
this._filterUpdateEntitiesWithInstall(
|
||||
this._filterUpdateEntitiesParameterized(
|
||||
this.hass.states,
|
||||
this.hass.entities
|
||||
);
|
||||
@@ -291,6 +291,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
.total=${totalUpdates}
|
||||
.updateEntities=${canInstallUpdates}
|
||||
.isInstallable=${true}
|
||||
></ha-config-updates>
|
||||
${totalUpdates > canInstallUpdates.length
|
||||
? html`
|
||||
@@ -348,14 +349,16 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
showShortcutsDialog(this);
|
||||
}
|
||||
|
||||
private _filterUpdateEntitiesWithInstall = memoizeOne(
|
||||
private _filterUpdateEntitiesParameterized = memoizeOne(
|
||||
(
|
||||
entities: HomeAssistant["states"],
|
||||
entityRegistry: HomeAssistant["entities"]
|
||||
): { updates: UpdateEntity[]; total: number } => {
|
||||
const updates = filterUpdateEntitiesWithInstall(entities).filter(
|
||||
(entity) => !entityRegistry[entity.entity_id]?.hidden
|
||||
);
|
||||
const updates = filterUpdateEntitiesParameterized(
|
||||
entities,
|
||||
false,
|
||||
false
|
||||
).filter((entity) => !entityRegistry[entity.entity_id]?.hidden);
|
||||
|
||||
return {
|
||||
updates: updates.slice(0, updates.length === 3 ? updates.length : 2),
|
||||
|
||||
@@ -32,6 +32,8 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Number }) public total?: number;
|
||||
|
||||
@property({ attribute: false }) public isInstallable = true;
|
||||
|
||||
@state() private _devices?: DeviceRegistryEntry[];
|
||||
|
||||
@state() private _entities?: EntityRegistryEntry[];
|
||||
@@ -89,9 +91,16 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
|
||||
|
||||
return html`
|
||||
<div class="title" role="heading" aria-level="2">
|
||||
${this.hass.localize("ui.panel.config.updates.title", {
|
||||
count: this.total || this.updateEntities.length,
|
||||
})}
|
||||
${this.isInstallable
|
||||
? this.hass.localize("ui.panel.config.updates.title", {
|
||||
count: this.total || this.updateEntities.length,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.updates.title_not_installable",
|
||||
{
|
||||
count: this.total || this.updateEntities.length,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<ha-md-list>
|
||||
${updates.map((entity) => {
|
||||
|
||||
@@ -114,6 +114,15 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
},
|
||||
],
|
||||
dashboard_2: [
|
||||
{
|
||||
path: "/config/matter",
|
||||
name: "Matter",
|
||||
iconPath:
|
||||
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
|
||||
iconColor: "#2458B3",
|
||||
component: "matter",
|
||||
translationKey: "matter",
|
||||
},
|
||||
{
|
||||
path: "/config/zha",
|
||||
name: "Zigbee",
|
||||
|
||||
@@ -260,8 +260,6 @@ export class DialogHelperDetail extends LitElement {
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
class=${classMap({ "button-left": !this._domain })}
|
||||
scrimClickAction
|
||||
escapeKeyAction
|
||||
.hideActions=${!this._domain}
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
|
||||
@@ -225,6 +225,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _diagnosticHandlers?: Record<string, boolean>;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "helpers-table-filters",
|
||||
@@ -1021,6 +1023,50 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
this._filteredStateItems = items ? [...items] : undefined;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("location-changed", this._locationChanged);
|
||||
window.addEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("location-changed", this._locationChanged);
|
||||
window.removeEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _popState = () => {
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _setFiltersFromUrl() {
|
||||
const device = this._searchParms.get("device");
|
||||
const label = this._searchParms.get("label");
|
||||
const category = this._searchParms.get("category");
|
||||
|
||||
if (!category && !label && !device) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._filter = history.state?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-devices": device ? [device] : [],
|
||||
"ha-filter-labels": label ? [label] : [],
|
||||
"ha-filter-categories": category ? [category] : [],
|
||||
};
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
this._filteredItems = {};
|
||||
@@ -1121,7 +1167,7 @@ ${rejected
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
this._setFiltersFromUrl();
|
||||
this._fetchEntitySources();
|
||||
|
||||
if (isComponentLoaded(this.hass, "diagnostics")) {
|
||||
@@ -1234,6 +1280,10 @@ ${rejected
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
|
||||
if (!this._entityEntries || !this._configEntries) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -327,7 +327,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
return html`<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
scrimClickAction
|
||||
hideActions
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
|
||||
@@ -23,7 +23,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isDevVersion } from "../../../common/config/version";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
@@ -213,10 +212,7 @@ class HaConfigEntryRow extends LitElement {
|
||||
? html`<ha-button slot="end" @click=${this._handleEnable}>
|
||||
${this.hass.localize("ui.common.enable")}
|
||||
</ha-button>`
|
||||
: configPanel &&
|
||||
(item.domain !== "matter" ||
|
||||
isDevVersion(this.hass.config.version)) &&
|
||||
!stateText
|
||||
: configPanel && !stateText
|
||||
? html`<a
|
||||
slot="end"
|
||||
href=${`/${configPanel}?config_entry=${item.entry_id}`}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { mdiAlertCircle, mdiCheckCircle, mdiPlus } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-expansion-panel";
|
||||
import "../../../../../components/ha-fab";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import {
|
||||
acceptSharedMatterDevice,
|
||||
canCommissionMatterExternal,
|
||||
@@ -18,7 +26,6 @@ import {
|
||||
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@customElement("matter-config-dashboard")
|
||||
export class MatterConfigDashboard extends LitElement {
|
||||
@@ -26,6 +33,8 @@ export class MatterConfigDashboard extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _configEntry?: ConfigEntry;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _unsub?: UnsubscribeFunc;
|
||||
@@ -35,10 +44,33 @@ export class MatterConfigDashboard extends LitElement {
|
||||
this._stopRedirect();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.hass) {
|
||||
this._fetchConfigEntry();
|
||||
}
|
||||
}
|
||||
|
||||
private _matterDeviceCount = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): number =>
|
||||
Object.values(devices).filter((device) =>
|
||||
device.identifiers.some((identifier) => identifier[0] === "matter")
|
||||
).length
|
||||
);
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._configEntry) {
|
||||
return nothing;
|
||||
}
|
||||
const isOnline = this._configEntry.state === "loaded";
|
||||
return html`
|
||||
<hass-subpage .narrow=${this.narrow} .hass=${this.hass} header="Matter">
|
||||
${isComponentLoaded(this.hass, "otbr")
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
header="Matter"
|
||||
has-fab
|
||||
>
|
||||
${isComponentLoaded(this.hass, "thread")
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@@ -51,53 +83,114 @@ export class MatterConfigDashboard extends LitElement {
|
||||
)}</ha-button
|
||||
>
|
||||
`
|
||||
: ""}
|
||||
<div class="content">
|
||||
<ha-card header="Matter">
|
||||
<ha-alert alert-type="warning"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.experimental_note"
|
||||
)}</ha-alert
|
||||
>
|
||||
: nothing}
|
||||
<div class="container">
|
||||
<ha-card class="network-status">
|
||||
<div class="card-content">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: ""}
|
||||
${this.hass.localize("ui.panel.config.matter.panel.add_devices")}
|
||||
<div class="heading">
|
||||
<div class="icon">
|
||||
<ha-svg-icon
|
||||
.path=${isOnline ? mdiCheckCircle : mdiAlertCircle}
|
||||
class=${isOnline ? "online" : "offline"}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
Matter
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.status_title"
|
||||
)}:
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.matter.panel.status_${isOnline ? "online" : "offline"}`
|
||||
)}<br />
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.devices",
|
||||
{ count: this._matterDeviceCount(this.hass.devices) }
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${canCommissionMatterExternal(this.hass)
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._startMobileCommissioning}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.mobile_app_commisioning"
|
||||
)}</ha-button
|
||||
>`
|
||||
: ""}
|
||||
<ha-button appearance="plain" @click=${this._commission}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.commission_device"
|
||||
)}</ha-button
|
||||
<ha-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
appearance="plain"
|
||||
size="small"
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_shared_device"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._setWifi}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_wifi_credentials"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._setThread}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_thread_credentials"
|
||||
)}</ha-button
|
||||
${this.hass.localize("ui.panel.config.devices.caption")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.entities.caption")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.developer_tools_title"
|
||||
)}
|
||||
.secondary=${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.developer_tools_description"
|
||||
)}
|
||||
>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
<div class="dev-tools-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.developer_tools_info"
|
||||
)}
|
||||
</p>
|
||||
<div class="dev-tools-actions">
|
||||
${canCommissionMatterExternal(this.hass)
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._startMobileCommissioning}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.mobile_app_commisioning"
|
||||
)}</ha-button
|
||||
>`
|
||||
: nothing}
|
||||
<ha-button appearance="plain" @click=${this._commission}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.commission_device"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_shared_device"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._setWifi}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_wifi_credentials"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._setThread}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_thread_credentials"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
|
||||
<a href="/config/matter/add" slot="fab">
|
||||
<ha-fab
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_device"
|
||||
)}
|
||||
extended
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-fab>
|
||||
</a>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
@@ -236,27 +329,101 @@ export class MatterConfigDashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
ha-alert[alert-type="warning"] {
|
||||
position: relative;
|
||||
top: -16px;
|
||||
}
|
||||
.content {
|
||||
padding: 24px 0 32px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
direction: ltr;
|
||||
}
|
||||
ha-card:first-child {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
a[slot="toolbar-icon"] {
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
private async _fetchConfigEntry(): Promise<void> {
|
||||
const configEntries = await getConfigEntries(this.hass, {
|
||||
domain: "matter",
|
||||
});
|
||||
if (configEntries.length) {
|
||||
this._configEntry = configEntries[0];
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
ha-card {
|
||||
margin: auto;
|
||||
margin-top: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
ha-card .card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.network-status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.network-status div.heading .icon {
|
||||
margin-inline-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.network-status div.heading ha-svg-icon {
|
||||
--mdc-icon-size: 48px;
|
||||
}
|
||||
|
||||
.network-status div.heading .details {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
}
|
||||
|
||||
.network-status small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
.network-status .online {
|
||||
color: var(--state-on-color, var(--success-color));
|
||||
}
|
||||
|
||||
.network-status .offline {
|
||||
color: var(--error-color, var(--error-color));
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
margin: auto;
|
||||
margin-top: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
background: var(--card-background-color);
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
--expansion-panel-summary-padding: var(--ha-space-2) var(--ha-space-4);
|
||||
--expansion-panel-content-padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.dev-tools-content {
|
||||
padding: 0 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.dev-tools-content p {
|
||||
margin: 0 0 var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.dev-tools-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
a[slot="toolbar-icon"] {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a[slot="fab"] {
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1158,13 +1158,19 @@ ${rejected
|
||||
private async _duplicate(scene) {
|
||||
if (scene.attributes.id) {
|
||||
const config = await getSceneConfig(this.hass, scene.attributes.id);
|
||||
showSceneEditor({
|
||||
...config,
|
||||
id: undefined,
|
||||
name: `${config?.name} (${this.hass.localize(
|
||||
"ui.panel.config.scene.picker.duplicate"
|
||||
)})`,
|
||||
});
|
||||
const entityRegEntry = this._entityReg.find(
|
||||
(reg) => reg.entity_id === scene.entity_id
|
||||
);
|
||||
showSceneEditor(
|
||||
{
|
||||
...config,
|
||||
id: undefined,
|
||||
name: `${config?.name} (${this.hass.localize(
|
||||
"ui.panel.config.scene.picker.duplicate"
|
||||
)})`,
|
||||
},
|
||||
entityRegEntry?.area_id || undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
label: this.hass.localize(
|
||||
"ui.panel.developer-tools.tabs.statistics.fix_issue.fix"
|
||||
),
|
||||
type: "icon",
|
||||
template: (statistic) =>
|
||||
html`${statistic.issues
|
||||
? html`<ha-button
|
||||
|
||||
@@ -283,13 +283,18 @@ class PanelEnergy extends LitElement {
|
||||
["grid", "solar", "battery"].includes(source.type)
|
||||
);
|
||||
|
||||
const hasPower =
|
||||
this._prefs.energy_sources.some(
|
||||
(source) =>
|
||||
(source.type === "solar" && source.stat_rate) ||
|
||||
(source.type === "battery" && source.stat_rate) ||
|
||||
(source.type === "grid" && source.power?.length)
|
||||
) || this._prefs.device_consumption.some((device) => device.stat_rate);
|
||||
const hasPowerSource = this._prefs.energy_sources.some(
|
||||
(source) =>
|
||||
(source.type === "solar" && source.stat_rate) ||
|
||||
(source.type === "battery" && source.stat_rate) ||
|
||||
(source.type === "grid" && source.power?.length)
|
||||
);
|
||||
|
||||
const hasDevicePower = this._prefs.device_consumption.some(
|
||||
(device) => device.stat_rate
|
||||
);
|
||||
|
||||
const hasPower = hasPowerSource || hasDevicePower;
|
||||
|
||||
const hasWater =
|
||||
this._prefs.energy_sources.some((source) => source.type === "water") ||
|
||||
@@ -314,7 +319,10 @@ class PanelEnergy extends LitElement {
|
||||
if (hasPower) {
|
||||
views.push(POWER_VIEW);
|
||||
}
|
||||
if (views.length > 1) {
|
||||
if (
|
||||
hasPowerSource ||
|
||||
[hasEnergy, hasGas, hasWater].filter(Boolean).length > 1
|
||||
) {
|
||||
views.unshift(OVERVIEW_VIEW);
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -67,6 +67,19 @@ export const SUM_DEVICE_CLASSES = [
|
||||
"water",
|
||||
];
|
||||
|
||||
// Additional sources for sensor device classes from entity attributes
|
||||
// Maps device_class -> array of { domain, attribute } to include in aggregation
|
||||
export const SENSOR_ATTRIBUTE_SOURCES: Record<
|
||||
string,
|
||||
{ domain: string; attribute: string }[]
|
||||
> = {
|
||||
temperature: [{ domain: "climate", attribute: "current_temperature" }],
|
||||
humidity: [
|
||||
{ domain: "climate", attribute: "current_humidity" },
|
||||
{ domain: "humidifier", attribute: "current_humidity" },
|
||||
],
|
||||
};
|
||||
|
||||
export interface AreaCardFeatureContext extends LovelaceCardFeatureContext {
|
||||
exclude_entities?: string[];
|
||||
}
|
||||
@@ -251,6 +264,24 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
);
|
||||
|
||||
private _domainEntityIds = memoizeOne(
|
||||
(
|
||||
entities: HomeAssistant["entities"],
|
||||
areaId: string,
|
||||
domains: string[],
|
||||
excludeEntities?: string[]
|
||||
): string[] => {
|
||||
const filter = generateEntityFilter(this.hass, {
|
||||
area: areaId,
|
||||
entity_category: "none",
|
||||
domain: domains,
|
||||
});
|
||||
return Object.keys(entities).filter(
|
||||
(id) => filter(id) && !excludeEntities?.includes(id)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _computeActiveAlertStates(): HassEntity[] {
|
||||
const areaId = this._config?.area;
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
@@ -359,58 +390,91 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
: this.hass.formatEntityState(stateObj);
|
||||
}
|
||||
|
||||
const entityIds = groupedEntities.get(sensorClass);
|
||||
const sensorEntityIds = groupedEntities.get(sensorClass) || [];
|
||||
const values: number[] = [];
|
||||
let uom: string | undefined;
|
||||
|
||||
if (!entityIds) {
|
||||
return undefined;
|
||||
// Track devices that have sensor entities contributing values
|
||||
// to avoid duplicate readings from climate/humidifier attributes
|
||||
const devicesWithSensorValues = new Set<string>();
|
||||
|
||||
for (const entityId of sensorEntityIds) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (
|
||||
stateObj &&
|
||||
!isUnavailableState(stateObj.state) &&
|
||||
isNumericState(stateObj) &&
|
||||
!isNaN(Number(stateObj.state))
|
||||
) {
|
||||
if (!uom) {
|
||||
uom = stateObj.attributes.unit_of_measurement;
|
||||
}
|
||||
if (stateObj.attributes.unit_of_measurement === uom) {
|
||||
values.push(Number(stateObj.state));
|
||||
// Track the device this sensor belongs to
|
||||
const entityEntry = this.hass.entities[entityId];
|
||||
if (entityEntry?.device_id) {
|
||||
devicesWithSensorValues.add(entityEntry.device_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all entities have state
|
||||
const entities = entityIds
|
||||
.map((entityId) => this.hass.states[entityId])
|
||||
.filter(Boolean);
|
||||
// Collect values from additional attribute sources
|
||||
const attrSources = SENSOR_ATTRIBUTE_SOURCES[sensorClass];
|
||||
if (attrSources) {
|
||||
const domains = [...new Set(attrSources.map((s) => s.domain))];
|
||||
const attrEntityIds = this._domainEntityIds(
|
||||
this.hass.entities,
|
||||
area.area_id,
|
||||
domains,
|
||||
excludeEntities
|
||||
);
|
||||
|
||||
if (entities.length === 0) {
|
||||
return undefined;
|
||||
for (const entityId of attrEntityIds) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) continue;
|
||||
|
||||
// Skip if this entity's device already has a sensor contributing values
|
||||
const entityEntry = this.hass.entities[entityId];
|
||||
if (
|
||||
entityEntry?.device_id &&
|
||||
devicesWithSensorValues.has(entityEntry.device_id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const domain = entityId.split(".")[0];
|
||||
const source = attrSources.find((s) => s.domain === domain);
|
||||
if (!source) continue;
|
||||
|
||||
const attrValue = stateObj.attributes[source.attribute];
|
||||
if (attrValue == null || isNaN(Number(attrValue))) continue;
|
||||
|
||||
if (!uom) {
|
||||
// Determine unit from attribute
|
||||
uom = this._getAttributeUnit(sensorClass, domain);
|
||||
}
|
||||
values.push(Number(attrValue));
|
||||
}
|
||||
}
|
||||
|
||||
// If only one entity, return its formatted state
|
||||
if (entities.length === 1) {
|
||||
const stateObj = entities[0];
|
||||
return isUnavailableState(stateObj.state)
|
||||
? ""
|
||||
: this.hass.formatEntityState(stateObj);
|
||||
}
|
||||
|
||||
// Use the first entity's unit_of_measurement for formatting
|
||||
const uom = entities.find(
|
||||
(entity) => entity.attributes.unit_of_measurement
|
||||
)?.attributes.unit_of_measurement;
|
||||
|
||||
// Ensure all entities have the same unit_of_measurement
|
||||
const validEntities = entities.filter(
|
||||
(entity) =>
|
||||
entity.attributes.unit_of_measurement === uom &&
|
||||
isNumericState(entity) &&
|
||||
!isNaN(Number(entity.state))
|
||||
);
|
||||
|
||||
if (validEntities.length === 0) {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = SUM_DEVICE_CLASSES.includes(sensorClass)
|
||||
? this._computeSumState(validEntities)
|
||||
: this._computeMedianState(validEntities);
|
||||
? values.reduce((acc, v) => acc + v, 0)
|
||||
: this._computeMedianValue(values);
|
||||
|
||||
const formattedAverage = formatNumber(value, this.hass!.locale, {
|
||||
const formattedValue = formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const formattedUnit = uom
|
||||
? `${blankBeforeUnit(uom, this.hass!.locale)}${uom}`
|
||||
? `${blankBeforeUnit(uom, this.hass.locale)}${uom}`
|
||||
: "";
|
||||
|
||||
return `${formattedAverage}${formattedUnit}`;
|
||||
return `${formattedValue}${formattedUnit}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -418,20 +482,25 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
return sensorStates;
|
||||
}
|
||||
|
||||
private _computeSumState(entities: HassEntity[]): number {
|
||||
return entities.reduce((acc, entity) => acc + Number(entity.state), 0);
|
||||
private _getAttributeUnit(sensorClass: string, domain: string): string {
|
||||
// Return the expected unit for attributes from specific domains
|
||||
if (sensorClass === "temperature" && domain === "climate") {
|
||||
return this.hass.config.unit_system.temperature;
|
||||
}
|
||||
if (sensorClass === "humidity") {
|
||||
return "%";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private _computeMedianState(entities: HassEntity[]): number {
|
||||
const sortedStates = entities
|
||||
.map((entity) => Number(entity.state))
|
||||
.sort((a, b) => a - b);
|
||||
if (sortedStates.length % 2 === 0) {
|
||||
const medianIndex = sortedStates.length / 2;
|
||||
return (sortedStates[medianIndex] + sortedStates[medianIndex - 1]) / 2;
|
||||
private _computeMedianValue(values: number[]): number {
|
||||
const sortedValues = [...values].sort((a, b) => a - b);
|
||||
if (sortedValues.length % 2 === 0) {
|
||||
const medianIndex = sortedValues.length / 2;
|
||||
return (sortedValues[medianIndex] + sortedValues[medianIndex - 1]) / 2;
|
||||
}
|
||||
const medianIndex = Math.floor(sortedStates.length / 2);
|
||||
return sortedStates[medianIndex];
|
||||
const medianIndex = Math.floor(sortedValues.length / 2);
|
||||
return sortedValues[medianIndex];
|
||||
}
|
||||
|
||||
private _featurePosition = memoizeOne((config: AreaCardConfig) => {
|
||||
|
||||
@@ -1,63 +1,116 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceCard } from "../types";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import type { LovelaceCard, LovelaceCardEditor } from "../types";
|
||||
import type { EmptyStateCardConfig } from "./types";
|
||||
|
||||
@customElement("hui-empty-state-card")
|
||||
export class HuiEmptyStateCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
await import("../editor/config-elements/hui-empty-state-card-editor");
|
||||
return document.createElement("hui-empty-state-card-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(): EmptyStateCardConfig {
|
||||
return {
|
||||
type: "empty-state",
|
||||
title: "Welcome Home",
|
||||
content: "This is an empty state card.",
|
||||
};
|
||||
}
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: EmptyStateCardConfig;
|
||||
|
||||
public getCardSize(): number {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public setConfig(_config: EmptyStateCardConfig): void {}
|
||||
public setConfig(config: EmptyStateCardConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.empty_state.title"
|
||||
)}
|
||||
class=${classMap({
|
||||
"content-only": this._config.content_only ?? false,
|
||||
})}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.empty_state.no_devices"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" href="/config/integrations/dashboard">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.empty_state.go_to_integrations_page"
|
||||
)}
|
||||
</ha-button>
|
||||
<div class="container">
|
||||
${this._config.icon
|
||||
? html`<ha-icon .icon=${this._config.icon}></ha-icon>`
|
||||
: nothing}
|
||||
${this._config.title ? html`<h1>${this._config.title}</h1>` : nothing}
|
||||
${this._config.content
|
||||
? html`<p>${this._config.content}</p>`
|
||||
: nothing}
|
||||
${this._config.tap_action && this._config.action_button_text
|
||||
? html`
|
||||
<ha-button @click=${this._handleAction}>
|
||||
${this._config.action_button_text}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(): void {
|
||||
if (this._config?.tap_action && this.hass) {
|
||||
handleAction(this, this.hass, this._config, "tap");
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
margin-top: -1em;
|
||||
padding: 16px;
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-actions a {
|
||||
text-decoration: none;
|
||||
ha-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ha-button {
|
||||
margin-left: -8px;
|
||||
margin-inline-start: -8px;
|
||||
margin-inline-end: initial;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
gap: var(--ha-space-4);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
ha-icon {
|
||||
--mdc-icon-size: var(--ha-space-12);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: 500;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.content-only {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -58,8 +58,12 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
|
||||
}
|
||||
|
||||
export interface EmptyStateCardConfig extends LovelaceCardConfig {
|
||||
content: string;
|
||||
content_only?: boolean;
|
||||
icon?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
action_button_text?: string;
|
||||
tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface EntityCardConfig extends LovelaceCardConfig {
|
||||
|
||||
@@ -21,5 +21,8 @@ export const confirmAction = async (
|
||||
hass.localize("ui.panel.lovelace.cards.actions.action_confirmation", {
|
||||
action,
|
||||
}),
|
||||
title: config.title,
|
||||
dismissText: config.dismiss_text,
|
||||
confirmText: config.confirm_text,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -89,6 +89,9 @@ export const handleAction = async (
|
||||
) ||
|
||||
actionConfig.action,
|
||||
}),
|
||||
title: actionConfig.confirmation.title,
|
||||
dismissText: actionConfig.confirmation.dismiss_text,
|
||||
confirmText: actionConfig.confirmation.confirm_text,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mdiGestureTap } from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { EmptyStateCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
content_only: optional(boolean()),
|
||||
icon: optional(string()),
|
||||
title: optional(string()),
|
||||
content: optional(string()),
|
||||
action_button_text: optional(string()),
|
||||
tap_action: optional(actionConfigStruct),
|
||||
})
|
||||
);
|
||||
|
||||
@customElement("hui-empty-state-card-editor")
|
||||
export class HuiEmptyStateCardEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: EmptyStateCardConfig;
|
||||
|
||||
public setConfig(config: EmptyStateCardConfig): void {
|
||||
assert(config, cardConfigStruct);
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "style",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
options: (
|
||||
[
|
||||
{ value: "card", image: "card" },
|
||||
{ value: "content-only", image: "text_only" },
|
||||
] as const
|
||||
).map((style) => ({
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.empty_state.style_options.${style.value}`
|
||||
),
|
||||
image: {
|
||||
src: `/static/images/form/markdown_${style.image}.svg`,
|
||||
src_dark: `/static/images/form/markdown_${style.image}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
value: style.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "icon", selector: { icon: {} } },
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{ name: "content", selector: { text: { multiline: true } } },
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{ name: "action_button_text", selector: { text: {} } },
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
style: this._config.content_only ? "content-only" : "card",
|
||||
};
|
||||
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const config = { ...ev.detail.value };
|
||||
|
||||
if (config.style === "content-only") {
|
||||
config.content_only = true;
|
||||
} else {
|
||||
delete config.content_only;
|
||||
}
|
||||
delete config.style;
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "style":
|
||||
case "content":
|
||||
case "action_button_text":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.empty_state.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-empty-state-card-editor": HuiEmptyStateCardEditor;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,20 @@ class HuiSelectEntityRow extends LitElement implements LovelaceRow {
|
||||
|
||||
forwardHaptic(this, "light");
|
||||
|
||||
setSelectOption(this.hass!, stateObj.entity_id, option);
|
||||
setSelectOption(this.hass!, stateObj.entity_id, option)
|
||||
.catch((_err) => {
|
||||
// silently swallow exception
|
||||
})
|
||||
.finally(() =>
|
||||
setTimeout(() => {
|
||||
const newStateObj = this.hass!.states[this._config!.entity];
|
||||
if (newStateObj === stateObj) {
|
||||
const select = this.shadowRoot?.querySelector("ha-select");
|
||||
const index = select?.options.indexOf(stateObj.state) ?? -1;
|
||||
select?.select(index);
|
||||
}
|
||||
}, 2000)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { HeadingCardConfig } from "../../cards/types";
|
||||
import type {
|
||||
EmptyStateCardConfig,
|
||||
HeadingCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { computeAreaTileCardConfig } from "../areas/helpers/areas-strategy-helper";
|
||||
import {
|
||||
getSummaryLabel,
|
||||
@@ -354,6 +357,26 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
// No sections, show empty state
|
||||
if (sections.length === 0) {
|
||||
return {
|
||||
type: "panel",
|
||||
cards: [
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:sofa-outline",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.areas.empty_state_title"
|
||||
),
|
||||
content: hass.localize(
|
||||
"ui.panel.lovelace.strategy.areas.empty_state_content"
|
||||
),
|
||||
} as EmptyStateCardConfig,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Allow between 2 and 3 columns (the max should be set to define the width of the header)
|
||||
const maxColumns = clamp(sections.length, 2, 3);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AreasDisplayValue } from "../../../../components/ha-areas-display-
|
||||
import { getEnergyPreferences } from "../../../../data/energy";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { EmptyStateCardConfig } from "../../cards/types";
|
||||
import { generateDefaultViewConfig } from "../../common/generate-lovelace-config";
|
||||
|
||||
export interface OriginalStatesViewStrategyConfig {
|
||||
@@ -64,9 +65,33 @@ export class OriginalStatesViewStrategy extends ReactiveElement {
|
||||
|
||||
// User has no entities
|
||||
if (view.cards!.length === 0) {
|
||||
view.cards!.push({
|
||||
type: "empty-state",
|
||||
});
|
||||
return {
|
||||
type: "panel",
|
||||
cards: [
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.original-states.empty_state_title"
|
||||
),
|
||||
content: hass.localize(
|
||||
"ui.panel.lovelace.strategy.original-states.empty_state_content"
|
||||
),
|
||||
...(hass.user?.is_admin
|
||||
? {
|
||||
action_button_text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.original-states.empty_state_action"
|
||||
),
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/config/integrations/dashboard",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as EmptyStateCardConfig,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return view;
|
||||
|
||||
@@ -2383,6 +2383,7 @@
|
||||
"updates_refreshed": "State of {count} {count, plural,\n one {update}\n other {updates}\n} refreshed",
|
||||
"checking_updates": "Checking for updates...",
|
||||
"title": "{count} {count, plural,\n one {update}\n other {updates}\n}",
|
||||
"title_not_installable": "{count} not installable {count, plural,\n one {update}\n other {updates}\n}",
|
||||
"unable_to_fetch": "Unable to load updates",
|
||||
"more_updates": "Show all updates",
|
||||
"show": "show",
|
||||
@@ -6844,9 +6845,15 @@
|
||||
},
|
||||
"matter": {
|
||||
"panel": {
|
||||
"thread_panel": "Visit Thread Panel",
|
||||
"experimental_note": "Matter is still in the early phase of development, it is not meant to be used in production. This panel is for development only.",
|
||||
"add_devices": "You can add Matter devices by commissioning them if they are not set up yet, or share them from another controller and enter the sharing code.",
|
||||
"thread_panel": "Visit Thread panel",
|
||||
"add_device": "Add device",
|
||||
"status_title": "status",
|
||||
"status_online": "online",
|
||||
"status_offline": "offline",
|
||||
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"developer_tools_title": "Developer tools",
|
||||
"developer_tools_description": "Advanced commissioning options for development",
|
||||
"developer_tools_info": "These options are intended for developers and advanced users. In most cases, use the \"Add device\" button in the bottom right corner instead.",
|
||||
"mobile_app_commisioning": "Commission device with mobile app",
|
||||
"commission_device": "Commission device",
|
||||
"add_shared_device": "Add shared device",
|
||||
@@ -7226,10 +7233,14 @@
|
||||
"lovelace": {
|
||||
"strategy": {
|
||||
"original-states": {
|
||||
"helpers": "[%key:ui::panel::config::helpers::caption%]"
|
||||
"helpers": "[%key:ui::panel::config::helpers::caption%]",
|
||||
"empty_state_title": "Welcome Home",
|
||||
"empty_state_content": "This page allows you to control your devices, however it looks like you have no devices set up yet.",
|
||||
"empty_state_action": "Go to the integrations page"
|
||||
},
|
||||
"areas": {
|
||||
"no_entities": "No entities in this area.",
|
||||
"empty_state_title": "No devices",
|
||||
"empty_state_content": "There are no devices assigned to this area yet. Assign devices to this area to see them here.",
|
||||
"sensors": "Sensors",
|
||||
"sensors_description": "To display temperature and humidity sensors in the overview and in the area view, add a sensor to that area and {edit_the_area} to configure related sensors.",
|
||||
"edit_the_area": "edit the area",
|
||||
@@ -7301,11 +7312,6 @@
|
||||
"no_url": "No URL to open specified",
|
||||
"no_action": "No action to run specified"
|
||||
},
|
||||
"empty_state": {
|
||||
"title": "Welcome Home",
|
||||
"no_devices": "This page allows you to control your devices, however it looks like you have no devices set up yet. Head to the integrations page to get started.",
|
||||
"go_to_integrations_page": "Go to the integrations page."
|
||||
},
|
||||
"entities": {
|
||||
"never_triggered": "Never triggered"
|
||||
},
|
||||
@@ -7977,6 +7983,17 @@
|
||||
"name": "Entity",
|
||||
"description": "The Entity card gives you a quick overview of your entity's state."
|
||||
},
|
||||
"empty_state": {
|
||||
"name": "Empty state",
|
||||
"description": "The Empty state card displays a centered message with an optional icon and action button.",
|
||||
"style": "Style",
|
||||
"style_options": {
|
||||
"card": "Card",
|
||||
"content-only": "Content only"
|
||||
},
|
||||
"content": "Content",
|
||||
"action_text": "Action button text"
|
||||
},
|
||||
"button": {
|
||||
"name": "Button",
|
||||
"description": "The Button card allows you to add buttons to perform tasks.",
|
||||
|
||||
Reference in New Issue
Block a user