Compare commits

...
Author SHA1 Message Date
Petar Petrov f6fdb8a3dc Block the choose-view action while the dashboard config loads 2026-08-26 11:03:50 +03:00
Petar Petrov 2b02c608d3 Show the selected dashboard's views in the choose-view dialog 2026-08-26 10:44:17 +03:00
Paul BotteinandGitHub fb35194041 Share favorites editor and security entity filter (#53785) 2026-08-26 09:11:48 +03:00
Paul BotteinandGitHub d4e3ec858e Remove pulse from security dashboard alerts (#53784) 2026-08-26 08:24:31 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6516b8de68 Update dependency @bundle-stats/plugin-webpack-filter to v4.22.3 (#53783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 18:10:10 +02:00
Paul BotteinandGitHub edb2f98d03 Show targets in automation traces (#53718) 2026-08-25 18:50:04 +03:00
Aidan TimsonandGitHub 5ae95a1ea2 Add favorites to Security dashboard (#53512)
* Add favorites to security dashboard

* Use contexts in favorites editor

* Update src/translations/en.json

Co-authored-by: Paul Bottein <[email protected]>

* Address security favorites review

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-25 17:46:03 +02:00
Aidan TimsonandGitHub d0371e506f Add active alerts to Security dashboard (#53031)
* Add security dashboard alerts

* Fix security alerts editor and card defaults

* Use consistent security editor icons

* Fix security dashboard alert state

* Fix lint

* Render security alerts as individual cards

* Use context data in security alerts editor

* Add pulse for alerts

Co-authored-by: Paul Bottein <[email protected]>

* Use explicit cover and lock alert states

* Fix test

* Remove security panel rendering tests

* Use complete states

Co-authored-by: Paul Bottein <[email protected]>

* Remove camera

Co-authored-by: Paul Bottein <[email protected]>

* open state only

Co-authored-by: Paul Bottein <[email protected]>

* Remove security entity filter cache

* Format

* Update security alert visibility tests

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-25 17:28:46 +02:00
Paul BotteinandGitHub 872205c352 Remove template and default-value tests (#53779) 2026-08-25 17:14:00 +03:00
43 changed files with 1612 additions and 1313 deletions
+1 -1
View File
@@ -143,7 +143,7 @@
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
+4 -1
View File
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
};
export const generateEntityFilter = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
filter: EntityFilter
): EntityFilterFunc => {
const domains = filter.domain
@@ -1,34 +1,57 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDelete } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/state-badge";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import type { HomeAssistant } from "../../../types";
import { customElement, property, state } from "lit/decorators";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../common/dom/fire_event";
import "./state-badge";
import "../ha-icon-button";
import "../ha-settings-row";
import {
internationalizationContext,
registriesContext,
} from "../../data/context";
declare global {
interface HASSDomEvents {
"delete-favorite-entity": { index: number };
}
interface HTMLElementTagNameMap {
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
"ha-favorite-entity-list-item": HaFavoriteEntityListItem;
}
}
@customElement("home-favorite-entity-list-item")
export class HomeFavoriteEntityListItem extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@customElement("ha-favorite-entity-list-item")
export class HaFavoriteEntityListItem extends LitElement {
@property({ attribute: "entity-id" }) public entityId!: string;
@property({ type: Number }) public index = 0;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected render() {
const stateObj = this.hass.states[this.entityId];
const stateObj = this._stateObj;
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
? computeEntityPickerDisplay(
{
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
)
: { primary: this.entityId, secondary: undefined };
return html`
@@ -42,7 +65,7 @@ export class HomeFavoriteEntityListItem extends LitElement {
}
<ha-icon-button
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.delete")}
.label=${this._i18n.localize("ui.common.delete")}
@click=${this._delete}
></ha-icon-button>
</ha-settings-row>
@@ -2,24 +2,28 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "./home-favorite-entity-list-item";
@customElement("home-favorites-editor")
export class HomeFavoritesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaEntityPicker } from "./ha-entity-picker";
import "./ha-entity-picker";
import "../ha-sortable";
import "../ha-svg-icon";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { ValueChangedEvent } from "../../types";
import "./ha-favorite-entity-list-item";
@customElement("ha-favorites-editor")
export class HaFavoritesEditor extends LitElement {
@property({ attribute: false }) public favorites: string[] = [];
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
protected render() {
return html`
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
@@ -27,22 +31,21 @@ export class HomeFavoritesEditor extends LitElement {
this.helper ? html`<p class="field-helper">${this.helper}</p>` : nothing
}
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="home-list">
<div class="favorites-list">
${repeat(
this.favorites,
(entityId) => entityId,
(entityId, index) => html`
<div class="home-list-item favorite-row">
<div class="favorite-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<home-favorite-entity-list-item
<ha-favorite-entity-list-item
class="favorite-content"
.hass=${this.hass}
.entityId=${entityId}
.index=${index}
@delete-favorite-entity=${this._remove}
></home-favorite-entity-list-item>
></ha-favorite-entity-list-item>
</div>
`
)}
@@ -50,10 +53,9 @@ export class HomeFavoritesEditor extends LitElement {
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
.addButtonLabel=${this.addButtonLabel}
.excludeEntities=${this.favorites}
.entityFilter=${this.entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
@@ -102,7 +104,7 @@ export class HomeFavoritesEditor extends LitElement {
color: var(--secondary-text-color);
font-size: 12px;
}
.home-list {
.favorites-list {
display: flex;
flex-direction: column;
}
@@ -131,6 +133,6 @@ export class HomeFavoritesEditor extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"home-favorites-editor": HomeFavoritesEditor;
"ha-favorites-editor": HaFavoritesEditor;
}
}
+189 -56
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -7,8 +8,16 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import { fullEntitiesContext, labelsContext } from "../../data/context";
import type { ConditionDescriptions } from "../../data/condition";
import {
conditionDescriptionsContext,
fullEntitiesContext,
labelsContext,
manifestsContext,
triggerDescriptionsContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { DomainManifestLookup } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { LogbookEntry } from "../../data/logbook";
import { describeAction } from "../../data/script_i18n";
@@ -17,7 +26,12 @@ import type {
ChooseActionTraceStep,
TraceExtended,
} from "../../data/trace";
import type { TargetSelector } from "../../data/selector";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import type { TriggerDescriptions } from "../../data/trigger";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
@@ -67,6 +81,18 @@ export class HaTracePathDetails extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions?: TriggerDescriptions;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
@@ -191,51 +217,8 @@ export class HaTracePathDetails extends LitElement {
)}`;
}
const selectedType = this.selected.type;
return html`
${
curPath === this.selected.path
? currentDetail.alias
? html`<h2>${currentDetail.alias}</h2>`
: selectedType === "trigger"
? html`<h2>
${describeTrigger(
migrateAutomationTrigger({
...currentDetail,
}) as Trigger,
this.hass,
this._entityReg
)}
</h2>`
: selectedType === "condition"
? html`<h2>
${describeCondition(
currentDetail,
this.hass,
this._entityReg
)}
</h2>`
: selectedType === "action"
? html`<h2>
${describeAction(
this.hass,
this._entityReg,
currentDetail
)}
</h2>`
: selectedType === "chooseOption"
? html`<h2>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: pathParts[pathParts.length - 1] }
)}
</h2>`
: nothing
: html`<h2>
${curPath.substring(this.selected.path.length + 1)}
</h2>`
}
${this._renderStepHeading(curPath, currentDetail, pathParts)}
${
data.length === 1
? nothing
@@ -246,17 +229,7 @@ export class HaTracePathDetails extends LitElement {
)}
</h3>`
}
${
curPath
.substring(this.selected.path.length + 1)
.includes("condition")
? html`[${describeCondition(
currentDetail,
this.hass,
this._entityReg
)}]<br />`
: nothing
}
${this._renderNestedCondition(curPath, currentDetail)}
${this.hass!.localize(
"ui.panel.config.automation.trace.path.executed",
{
@@ -324,6 +297,130 @@ export class HaTracePathDetails extends LitElement {
return parts;
}
private _renderStepHeading(
curPath: string,
currentDetail: any,
pathParts: string[]
) {
if (curPath !== this.selected.path) {
return html`<div class="heading">
<h2>${curPath.substring(this.selected.path.length + 1)}</h2>
</div>`;
}
const selectedType = this.selected.type;
const description = currentDetail.alias
? currentDetail.alias
: selectedType === "trigger"
? describeTrigger(
migrateAutomationTrigger({ ...currentDetail }) as Trigger,
this.hass,
this._entityReg
)
: selectedType === "condition"
? describeCondition(currentDetail, this.hass, this._entityReg)
: selectedType === "action"
? describeAction(
this.hass,
this._entityReg,
currentDetail,
undefined,
false,
this._manifests
)
: selectedType === "chooseOption"
? this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: pathParts[pathParts.length - 1] }
)
: undefined;
if (description === undefined) {
return nothing;
}
return html`<div class="heading">
<h2>${description}</h2>
${this._renderTargets(currentDetail, selectedType)}
</div>`;
}
private _renderNestedCondition(curPath: string, currentDetail: any) {
if (
!curPath.substring(this.selected.path.length + 1).includes("condition")
) {
return nothing;
}
return html`<div class="nested-condition">
${describeCondition(currentDetail, this.hass, this._entityReg)}
${this._renderTargets(currentDetail, "condition", "s")}
</div>`;
}
private _renderTargets(
config: any,
type: NodeInfo["type"],
size: "s" | "m" = "m"
) {
const target = this._getTarget(config, type);
if (!target) {
return nothing;
}
const targetSpec = this._getTargetSelector(config, type);
return html`<div class="targets">
<ha-automation-row-targets
.target=${target}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.size=${size}
interactive
></ha-automation-row-targets>
</div>`;
}
private _getTargetSelector(
config: any,
type: NodeInfo["type"]
): TargetSelector["target"] | undefined {
if (type === "trigger") {
return this._triggerDescriptions?.[config.trigger]?.target;
}
if (type === "condition") {
return this._conditionDescriptions?.[config.condition]?.target;
}
if (type === "action" && typeof config.action === "string") {
const [domain, service] = config.action.split(".", 2);
return this.hass.services?.[domain]?.[service]?.target;
}
return undefined;
}
private _getTarget(
config: any,
type: NodeInfo["type"]
): HassServiceTarget | undefined {
if (config.target) {
return config.target;
}
if (type === "trigger" || type === "condition") {
const element = type === "trigger" ? config.trigger : config.condition;
if (element === "state" || element === "numeric_state") {
return getEntityTarget(config.entity_id);
}
if (element === "device") {
return getDeviceTarget(config.device_id);
}
return undefined;
}
if (type === "action") {
return config.entity_id
? getEntityTarget(config.entity_id)
: getDeviceTarget(config.device_id);
}
return undefined;
}
private _renderSelectedConfig() {
if (!this.selected?.path) {
return nothing;
@@ -463,6 +560,42 @@ export class HaTracePathDetails extends LitElement {
min-height: 250px;
}
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
.heading h2 {
margin: 0;
}
.heading .targets {
margin-top: 0;
}
.targets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin-top: var(--ha-space-2);
}
.nested-condition {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
}
.nested-condition .targets {
margin-top: 0;
}
pre {
margin: 0;
}
+35 -223
View File
@@ -149,9 +149,6 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Leave the entities out of the sentence, for rows that render them as
// target badges.
hideEntities?: boolean;
}
export const describeTrigger = (
@@ -210,8 +207,7 @@ const tryDescribeTrigger = (
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -235,8 +231,7 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -267,12 +262,7 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
const states = hass.states;
if (trigger.trigger === "numeric_state") {
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
@@ -292,82 +282,23 @@ const describeLegacyTrigger = (
? describeDuration(hass.locale, trigger.for)
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.label`
);
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(`${triggerTranslationBaseKey}.numeric_state.label`);
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
if (trigger.above !== undefined && trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
if (trigger.above !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: formatNumericLimitValue(hass, trigger.above),
duration: duration,
}
);
}
if (trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.below`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
);
}
// State Trigger
if (trigger.trigger === "state") {
const states = hass.states;
const entityArray: string[] = ensureArray(trigger.entity_id);
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
@@ -463,39 +394,12 @@ const describeLegacyTrigger = (
duration = describeDuration(hass.locale, trigger.for) ?? "";
}
if (hideEntities) {
return hass.localize(
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
toString: toString,
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
return hass.localize(
`${triggerTranslationBaseKey}.state.description.full`,
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
hasEntity: entities.length !== 0 ? "true" : "false",
entity: formatListWithOrs(hass.locale, entities),
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
@@ -1037,8 +941,7 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -1064,8 +967,7 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1122,12 +1024,6 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id && !hideEntities) {
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.no_entity`
);
}
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
@@ -1184,51 +1080,14 @@ const describeLegacyCondition = (
duration = describeDuration(hass.locale, condition.for) || "";
}
if (hideEntities) {
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.full`,
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
numberOfEntities: entities.length,
// With "any", entities are joined with "or", which takes a singular
// verb in English even for multiple entities ("A or B is ...").
matchAny: condition.match === "any" ? "true" : "false",
entities:
condition.match === "any"
? formatListWithOrs(hass.locale, entities)
: formatListWithAnds(hass.locale, entities),
numberOfStates: states.length,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
@@ -1237,10 +1096,7 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
if (condition.condition === "numeric_state") {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
@@ -1257,64 +1113,20 @@ const describeLegacyCondition = (
: condition.attribute
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
if (condition.above !== undefined && condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
if (condition.above !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
above: formatNumericLimitValue(hass, condition.above),
}
);
}
if (condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.below`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
below: formatNumericLimitValue(hass, condition.below),
}
);
}
}
// Time condition
+13
View File
@@ -35,6 +35,18 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export type SecurityAlertSeverity = "alert" | "warning";
export interface SecurityAlertEntityConfig {
entity: string;
severity?: SecurityAlertSeverity;
}
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
}
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -51,6 +63,7 @@ declare global {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
}
}
@@ -62,9 +62,11 @@ import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import {
conditionDescriptionsContext,
fullEntitiesContext,
manifestsContext,
} from "../../../../data/context";
@@ -90,6 +92,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
@@ -205,6 +209,10 @@ export default class HaAutomationActionRow extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
@state() private _running = false;
@state() private _runResult?: {
@@ -292,13 +300,19 @@ export default class HaAutomationActionRow extends LitElement {
? this._extractTargets(this.action as ServiceAction)
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
: type === "condition"
? this._extractConditionTarget(this.action as Condition)
: undefined;
const serviceTargetSpec =
type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
type === "condition"
? this._conditionDescriptions?.[(this.action as Condition).condition]
?.target
: undefined;
: type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[
computeObjectId(action)
]?.target
: undefined;
const noteTooltipText = truncateWithEllipsis(
this.action.note?.trim() || "",
@@ -777,6 +791,28 @@ export default class HaAutomationActionRow extends LitElement {
return {};
}
private _extractConditionTarget(
condition: Condition
): HassServiceTarget | undefined {
if (typeof condition !== "object") {
return undefined;
}
if ("target" in condition && condition.target) {
return condition.target;
}
if (
(condition.condition === "state" ||
condition.condition === "numeric_state") &&
"entity_id" in condition
) {
return getEntityTarget(condition.entity_id);
}
if (condition.condition === "device" && "device_id" in condition) {
return getDeviceTarget(condition.device_id as string);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -226,9 +226,7 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
@@ -68,6 +68,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ type: Boolean })
public interactive = false;
@property({ reflect: true })
public size: "s" | "m" = "m";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -652,6 +655,23 @@ export class HaAutomationRowTargets extends LitElement {
align-items: center;
}
:host([size="s"]) {
min-height: 24px;
}
:host([size="s"]) .target {
height: 24px;
}
/* A default 24px icon would fill the whole small chip. */
:host([size="s"]) .target ha-icon,
:host([size="s"]) .target ha-svg-icon,
:host([size="s"]) .target ha-domain-icon,
:host([size="s"]) .target ha-floor-icon {
--mdc-icon-size: 16px;
}
:host([size="s"]) .target ha-floor-icon {
height: 24px;
}
button.target {
cursor: pointer;
}
@@ -251,9 +251,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
describeTrigger(this.trigger, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
+7 -5
View File
@@ -16,7 +16,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "../components/home-favorites-editor";
import "../../../components/entity/ha-favorites-editor";
import "../components/home-shortcuts-editor";
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
@@ -123,14 +123,16 @@ export class DialogEditHome
@value-changed=${this._welcomeChanged}
></ha-form>
<home-favorites-editor
.hass=${this.hass}
<ha-favorites-editor
.favorites=${this._state.favorite_entities}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
)}
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></home-favorites-editor>
></ha-favorites-editor>
<ha-form
.hass=${this.hass}
@@ -309,7 +311,7 @@ export class DialogEditHome
display: block;
}
home-favorites-editor {
ha-favorites-editor {
display: block;
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-4);
@@ -1,8 +1,15 @@
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../data/lovelace/config/types";
import {
fetchConfig,
isStrategyDashboard,
saveConfig,
} from "../../../data/lovelace/config/types";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
@@ -37,7 +44,15 @@ export const addEntitiesToLovelaceView = async (
return;
}
let lovelaceConfig;
// A strategy dashboard has no views of its own until the user takes control.
const hasViewList = (
config: LovelaceRawConfig | undefined
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
const hasViews = (config: LovelaceRawConfig | undefined) =>
hasViewList(config) && !!config.views?.length;
let lovelaceConfig: LovelaceRawConfig | undefined;
let urlPath: string | null = null;
if (mainLovelaceMode === "storage") {
try {
@@ -47,25 +62,36 @@ export const addEntitiesToLovelaceView = async (
}
}
if (!lovelaceConfig && storageDashs.length) {
// find first dashoard not in generated mode
if (!hasViews(lovelaceConfig)) {
// Prefer a dashboard that has views to add the card to, but keep the first
// usable one as a fallback so the user still gets the dashboard picker.
for (const storageDash of storageDashs) {
try {
// eslint-disable-next-line no-await-in-loop
lovelaceConfig = await fetchConfig(
const dashConfig = await fetchConfig(
hass.connection,
storageDash.url_path,
false
);
urlPath = storageDash.url_path;
break;
if (!hasViewList(dashConfig)) {
continue;
}
if (!hasViewList(lovelaceConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
}
if (hasViews(dashConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
break;
}
} catch (_err: any) {
// dashboard is in generated mode
}
}
}
if (!lovelaceConfig) {
if (!hasViewList(lovelaceConfig)) {
if (dashboards.length > storageDashs.length) {
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
showSuggestCardDialog(element, {
@@ -94,7 +120,7 @@ export const addEntitiesToLovelaceView = async (
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig: lovelaceConfig!,
lovelaceConfig,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, null, newConfig);
@@ -9,13 +9,14 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-list";
import "../../../../components/ha-radio-list-item";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import "../../../../components/ha-dialog";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { fetchConfig } from "../../../../data/lovelace/config/types";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { LOVELACE_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { SelectViewDialogParams } from "./show-select-view-dialog";
@@ -42,11 +43,15 @@ export class HuiDialogSelectView extends LitElement {
@state() private _selectedViewIdx = 0;
@state() private _loading = false;
@state() private _open = false;
public showDialog(params: SelectViewDialogParams): void {
this._config = params.lovelaceConfig;
this._urlPath = params.urlPath;
this._selectedViewIdx = 0;
this._loading = false;
this._params = params;
this._open = true;
if (this._params.allowDashboardChange) {
@@ -68,8 +73,6 @@ export class HuiDialogSelectView extends LitElement {
return nothing;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
return html`
<ha-dialog
.open=${this._open}
@@ -86,19 +89,19 @@ export class HuiDialogSelectView extends LitElement {
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
.disabled=${!this._dashboards.length}
.value=${this._urlPath || defaultPanel}
.value=${this._urlPath ?? LOVELACE_PANEL}
@selected=${this._dashboardChanged}
autofocus
.options=${this._dashboards
.map((dashboard) => ({
value: dashboard.url_path,
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
disabled: dashboard.mode !== "storage",
}))
.sort((a, b) =>
a.value === "lovelace"
a.value === LOVELACE_PANEL
? -1
: b.value === "lovelace"
: b.value === LOVELACE_PANEL
? 1
: a.label.localeCompare(b.label)
)}
@@ -106,54 +109,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-select>`
: nothing
}
${
!this._config || (this._config.views || []).length < 1
? html`<ha-alert alert-type="error"
>${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}</ha-alert
>`
: this._config.views.length > 1
? html`
<ha-list>
${this._config.views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${
this._config?.views.some(({ icon }) => icon)
? "icon"
: nothing
}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${
isStrategy && !this._params?.includeStrategyViews
}
?autofocus=${
idx === 0 && !this._params!.allowDashboardChange
}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`
: nothing
}
${this._renderViews()}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -164,7 +120,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._config || (this._config.views || []).length < 1}
.disabled=${!this._selectableConfig}
@click=${this._selectView}
>
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
@@ -174,30 +130,97 @@ export class HuiDialogSelectView extends LitElement {
`;
}
// While a config is loading the views on screen still belong to the
// previously selected dashboard, so nothing may be picked from them.
private get _selectableConfig(): LovelaceConfig | undefined {
return !this._loading && this._config?.views?.length
? this._config
: undefined;
}
private _renderViews() {
if (this._loading) {
return html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`;
}
if (!this._selectableConfig) {
return html`<ha-alert alert-type="error">
${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}
</ha-alert>`;
}
const views = this._selectableConfig.views;
if (views.length < 2) {
return nothing;
}
const hasIcon = views.some(({ icon }) => icon);
return html`
<ha-list>
${views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${hasIcon ? "icon" : nothing}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${isStrategy && !this._params?.includeStrategyViews}
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`;
}
private async _getDashboards() {
this._dashboards =
this._params!.dashboards || (await fetchDashboards(this.hass));
}
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
let urlPath: string | null = ev.detail.value;
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
if (urlPath === this._urlPath) {
return;
}
if (urlPath === "lovelace") {
urlPath = null;
}
this._urlPath = urlPath;
this._selectedViewIdx = 0;
this._loading = true;
let config: LovelaceConfig | undefined;
try {
this._config = (await fetchConfig(
config = (await fetchConfig(
this.hass.connection,
urlPath,
false
)) as LovelaceConfig;
} catch (_err: any) {
this._config = undefined;
config = undefined;
}
// Responses can resolve out of order.
if (urlPath !== this._urlPath) {
return;
}
this._config = config;
this._selectedViewIdx = 0;
this._loading = false;
}
private _viewChanged(e) {
@@ -209,10 +232,14 @@ export class HuiDialogSelectView extends LitElement {
}
private _selectView(): void {
const config = this._selectableConfig;
if (!config) {
return;
}
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
this._params!.viewSelectedCallback(
this._urlPath!,
this._config!,
config,
this._selectedViewIdx
);
this.closeDialog();
@@ -225,6 +252,10 @@ export class HuiDialogSelectView extends LitElement {
ha-select {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
}
mwc-radio-list-item {
direction: ltr;
}
@@ -0,0 +1,283 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import "../../../components/entity/ha-entity-picker";
import "../../../components/entity/state-badge";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { ValueChangedEvent } from "../../../types";
type SecurityEditorContext = SecurityEntityContext & {
language: ContextType<typeof internationalizationContext>["language"];
translationMetadata: ContextType<
typeof internationalizationContext
>["translationMetadata"];
};
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false })
public alertEntities: SecurityAlertEntityConfig[] = [];
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _entityContext?: SecurityEditorContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
changedProps.has("_states") ||
changedProps.has("_registries") ||
changedProps.has("_i18n")
) {
this._entityContext = {
states: this._states,
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
};
}
}
protected render() {
return html`
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="alert-list">
${repeat(
this.alertEntities,
(alertEntity) => alertEntity.entity,
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
)}
</div>
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_alert_entity"
)}
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
.entityFilter=${this._entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
}
private _renderAlertEntity(
alertEntity: SecurityAlertEntityConfig,
index: number
) {
const stateObj = this._states[alertEntity.entity];
const { primary, secondary } =
stateObj && this._entityContext
? computeEntityPickerDisplay(this._entityContext, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return html`
<div class="alert-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<ha-settings-row slim>
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
<span slot="heading">${primary}</span>
${
secondary
? html`<span slot="description">${secondary}</span>`
: nothing
}
<ha-control-select-menu
show-arrow
hide-label
.label=${this._i18n.localize(
"ui.panel.security.editor.severity.label"
)}
.value=${severity}
.options=${(["alert", "warning"] as const).map((option) => ({
value: option,
label: this._i18n.localize(
`ui.panel.security.editor.severity.${option}`
),
}))}
data-index=${index}
@wa-select=${this._severityChanged}
></ha-control-select-menu>
<ha-icon-button
.path=${mdiDelete}
.label=${this._i18n.localize("ui.common.delete")}
data-index=${index}
@click=${this._removeClicked}
></ha-icon-button>
</ha-settings-row>
</div>
`;
}
private _changed(next: SecurityAlertEntityConfig[]): void {
fireEvent(this, "value-changed", { value: next });
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private _getIndex(
ev: HASSDomCurrentTargetEvent<HTMLElement>
): number | undefined {
const index = Number(ev.currentTarget.dataset.index);
return Number.isInteger(index) ? index : undefined;
}
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
const next = [...this.alertEntities];
next.splice(index, 1);
this._changed(next);
}
}
private _severityChanged(
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
HASSDomCurrentTargetEvent<HTMLElement>
): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index === undefined) {
return;
}
const next = [...this.alertEntities];
next[index] = { ...next[index], severity: ev.detail.item.value };
this._changed(next);
}
private _add(
ev: ValueChangedEvent<string | undefined> &
HASSDomCurrentTargetEvent<HaEntityPicker>
): void {
ev.stopPropagation();
const entity = ev.detail.value;
if (!entity) return;
ev.currentTarget.value = "";
if (
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
) {
return;
}
this._changed([...this.alertEntities, { entity }]);
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const next = [...this.alertEntities];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
this._changed(next);
}
static styles = css`
:host {
display: block;
}
.alert-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.alert-row {
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
}
.handle {
cursor: grab;
color: var(--secondary-text-color);
flex-shrink: 0;
display: flex;
align-items: center;
min-height: 48px;
}
ha-settings-row {
flex: 1;
min-width: 0;
padding: 0;
gap: var(--ha-space-3);
min-height: 48px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
state-badge {
flex-shrink: 0;
width: 24px;
height: 24px;
--state-icon-color: var(--secondary-text-color);
}
[slot="heading"],
[slot="description"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ha-entity-picker {
display: block;
padding-top: var(--ha-space-3);
}
ha-icon-button {
--ha-icon-button-size: 40px;
}
ha-control-select-menu {
width: 118px;
--control-select-menu-height: 42px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"security-alerts-editor": SecurityAlertsEditor;
}
}
@@ -0,0 +1,236 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import "../../../components/entity/ha-favorites-editor";
import "../components/security-alerts-editor";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
@customElement("dialog-edit-security")
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
DialogMixin<EditSecurityDialogParams>(LitElement)
) {
@state() private _state?: SecurityFrontendSystemData;
@state() private _submitting = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
private _entityContext?: SecurityEntityContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_states") || changedProps.has("_registries")) {
this._entityContext = {
states: this._states,
...this._registries,
};
}
}
public connectedCallback(): void {
super.connectedCallback();
if (!this.params) {
return;
}
this._state = {
...this.params.config,
favorite_entities: this.params.config.favorite_entities
? [...this.params.config.favorite_entities]
: [],
alert_entities: this.params.config.alert_entities
? [...this.params.config.alert_entities]
: [],
};
this._initDirtyTracking({ type: "deep" }, this._state);
}
protected render() {
if (!this.params || !this._state) {
return nothing;
}
return html`
<ha-dialog
open
width="medium"
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
.headerSubtitle=${this._i18n.localize(
"ui.panel.security.editor.description"
)}
.preventScrimClose=${this.isDirtyState}
>
${this._renderMainEditor()}
<ha-dialog-footer slot="footer">
<ha-button
autofocus
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
>
${this._i18n.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderMainEditor() {
return html`
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
<div class="expansion-content">
<ha-favorites-editor
.favorites=${this._state?.favorite_entities ?? []}
.entityFilter=${this._entityFilter}
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></ha-favorites-editor>
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
<div class="expansion-content">
<security-alerts-editor
.alertEntities=${this._state?.alert_entities ?? []}
@value-changed=${this._alertEntitiesChanged}
></security-alerts-editor>
</div>
</ha-expansion-panel>
`;
}
private _alertEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
): void {
this._state = {
...this._state,
alert_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _favoriteEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
): void {
this._state = {
...this._state,
favorite_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private async _save(): Promise<void> {
if (!this.params || !this._state) return;
this._submitting = true;
try {
await this.params.saveConfig({
...this.params.config,
favorite_entities: this._state.favorite_entities?.length
? this._state.favorite_entities
: undefined,
alert_entities: this._state.alert_entities?.length
? this._state.alert_entities
: undefined,
});
this._markDirtyStateClean();
this.closeDialog();
} catch {
return;
} finally {
this._submitting = false;
}
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
ha-expansion-panel {
display: block;
--expansion-panel-content-padding: 0;
border-radius: var(--ha-border-radius-md);
--ha-card-border-radius: var(--ha-border-radius-md);
}
ha-expansion-panel + ha-expansion-panel {
margin-top: var(--ha-space-4);
}
.expansion-content {
padding: var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-security": DialogEditSecurity;
}
}
@@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
export interface EditSecurityDialogParams {
config: SecurityFrontendSystemData;
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
}
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
export const showEditSecurityDialog = (
element: HTMLElement,
params: EditSecurityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-edit-security",
dialogImport: loadEditSecurityDialog,
dialogParams: params,
});
};
+110 -12
View File
@@ -1,15 +1,24 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
@@ -30,10 +39,16 @@ class PanelSecurity extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: SecurityFrontendSystemData;
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +62,22 @@ class PanelSecurity extends LitElement {
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -63,22 +87,44 @@ class PanelSecurity extends LitElement {
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemData(
this.hass.connection,
"security"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
@@ -97,6 +143,16 @@ class PanelSecurity extends LitElement {
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
this.hass.user?.is_admin && this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -118,8 +174,18 @@ class PanelSecurity extends LitElement {
}
private async _setLovelace() {
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
SECURITY_LOVELACE_VIEW_CONFIG,
{
strategy: {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
},
},
this.hass
);
@@ -146,6 +212,38 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
if (!this.hass.user?.is_admin || !this._config) {
return;
}
showEditSecurityDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
});
};
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "security", config);
this._config = config;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.save_failed"),
duration: 0,
dismissable: true,
});
throw err;
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
});
await this._setLovelace();
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -0,0 +1,15 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { registriesContext, statesContext } from "../../data/context";
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
};
export const createSecurityEntityFilter =
(getContext: () => SecurityEntityContext | undefined) =>
(entity: HassEntity): boolean => {
const context = getContext();
return context ? isSecurityPanelEntity(context, entity) : false;
};
@@ -0,0 +1,89 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../common/entity/compute_domain";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import type { StateCondition } from "../../lovelace/common/validate-condition";
import type { AlertCardConfig } from "../../lovelace/cards/types";
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
"carbon_monoxide",
"gas",
"moisture",
"safety",
"smoke",
] as const;
type DangerBinarySensorDeviceClass =
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
const isDangerBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is DangerBinarySensorDeviceClass =>
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as DangerBinarySensorDeviceClass
);
export const computeDefaultSecurityAlertSeverity = (
stateObj?: HassEntity
): SecurityAlertSeverity => {
if (!stateObj) {
return "warning";
}
const domain = computeDomain(stateObj.entity_id);
if (domain === "alarm_control_panel") {
return "alert";
}
if (domain === "binary_sensor") {
const deviceClass = stateObj.attributes.device_class;
return typeof deviceClass === "string" &&
isDangerBinarySensorDeviceClass(deviceClass)
? "alert"
: "warning";
}
return "warning";
};
export const computeDefaultSecurityAlertVisibility = (
entityId: string
): StateCondition[] => {
const condition: StateCondition = {
condition: "state",
entity: entityId,
};
switch (computeDomain(entityId)) {
case "alarm_control_panel":
condition.state = "triggered";
break;
case "cover":
condition.state = "open";
break;
case "lock":
condition.state = ["jammed", "unlocked", "open"];
break;
default:
condition.state = "on";
}
return [condition];
};
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
): AlertCardConfig => {
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return {
type: "alert",
entity: alertEntity.entity,
color: severity === "alert" ? "red" : "amber",
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
@@ -15,12 +16,19 @@ import type {
LovelaceSectionRawConfig,
} from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
}
export const securityEntityFilters: EntityFilter[] = [
@@ -69,6 +77,17 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const isSecurityPanelEntity = (
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
generateEntityFilter(hass, filter)(stateObj.entity_id)
);
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
@@ -132,7 +151,7 @@ const processUnassignedEntities = (
@customElement("security-view-strategy")
export class SecurityViewStrategy extends ReactiveElement {
static async generate(
_config: SecurityViewStrategyConfig,
config: SecurityViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -149,6 +168,37 @@ export class SecurityViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, securityFilters);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
);
if (favoriteEntities.length > 0) {
sections.push({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
});
}
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -242,37 +292,87 @@ export class SecurityViewStrategy extends ReactiveElement {
const logbookEntityIds = [...entities, ...personEntities];
const sidebarSection: LovelaceSectionConfig | undefined =
hasLogbook && logbookEntityIds.length > 0
? {
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.activity"
),
heading_style: "title",
} as LovelaceCardConfig,
{
type: "logbook",
target: {
entity_id: logbookEntityIds,
},
hours_to_show: 24,
grid_options: { columns: 12 },
} satisfies LogbookCardConfig,
],
}
: undefined;
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities?.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (alertCards?.length) {
sidebarSections.push({
type: "grid",
visibility: [
{
condition: "or",
conditions: alertCards.map((alertCard) => ({
condition: "and",
conditions: alertCard.visibility!,
})),
},
],
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.active_alerts"
),
heading_style: "title",
},
...alertCards.map((alertCard) => ({
...alertCard,
grid_options: { columns: 12 },
})),
] satisfies LovelaceCardConfig[],
});
}
const hasLogbookSection = hasLogbook && logbookEntityIds.length > 0;
if (hasLogbookSection) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.activity"
),
heading_style: "title",
} as LovelaceCardConfig,
{
type: "logbook",
target: {
entity_id: logbookEntityIds,
},
hours_to_show: 24,
grid_options: { columns: 12 },
} satisfies LogbookCardConfig,
],
});
}
return {
type: "sections",
max_columns: 3,
sections: sections,
...(sidebarSection && {
...(sidebarSections.length > 0 && {
sidebar: {
sections: [sidebarSection],
sections: sidebarSections,
...(!hasLogbookSection && alertCards?.length
? {
visibility: [
{
condition: "or" as const,
conditions: alertCards.map((alertCard) => ({
condition: "and" as const,
conditions: alertCard.visibility!,
})),
},
],
}
: {}),
content_label: hass.localize(
"ui.panel.lovelace.strategy.security.devices"
),
+22 -10
View File
@@ -2640,6 +2640,25 @@
"learn_more": "Learn more"
}
},
"security": {
"editor": {
"title": "Edit security and safety page",
"description": "Configure your security and safety display preferences.",
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
"favorite_entities_description": "Pin entities to the top of the page.",
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
"active_alert_entities": "Active alert entities",
"active_alert_entities_description": "Display any entities that require attention.",
"add_alert_entity": "Add entity",
"severity": {
"label": "Severity",
"alert": "Alert",
"warning": "Warning"
},
"load_failed": "Failed to load security and safety page configuration",
"save_failed": "Failed to save security and safety page configuration"
}
},
"my": {
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
@@ -5386,7 +5405,6 @@
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
},
"for_type": {
@@ -5427,9 +5445,6 @@
"type_input": "Value of an entity",
"description": {
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
@@ -5684,9 +5699,6 @@
"value_template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::value_template%]",
"description": {
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
@@ -5705,8 +5717,6 @@
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"description": {
"picker": "Tests if an entity (or attribute) is in a specific state.",
"no_entity": "If state confirmed",
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
}
},
@@ -6036,7 +6046,7 @@
},
"check_condition": {
"description": {
"full": "Test {condition}"
"full": "Test: {condition}"
}
},
"set_conversation_response": {
@@ -9161,6 +9171,8 @@
"security": {
"devices": "Devices",
"other_devices": "Other devices",
"active_alerts": "Active alerts",
"favorites": "[%key:ui::panel::lovelace::strategy::home::favorites%]",
"activity": "Activity"
},
"climate": {
-47
View File
@@ -71,30 +71,6 @@ describe("ha-control-slider display rounding", () => {
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
const FAN_STEP = 100 / 91;
const ariaValueNow = (el: HaControlSlider) =>
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow");
const tooltipText = (el: HaControlSlider) =>
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
it("shows the fractional stepped value by default", async () => {
const el = await mountSlider({ step: FAN_STEP, value: 29 });
expect(tooltipText(el)).toBe("28.57");
expect(ariaValueNow(el)).toBe(el.steppedValue(29).toString());
});
it("rounds the displayed value to an integer when round-value is set", async () => {
const el = await mountSlider({
step: FAN_STEP,
value: 29,
roundValue: true,
});
expect(tooltipText(el)).toBe("29");
expect(ariaValueNow(el)).toBe("29");
});
it("still snaps to the real step grid when rounding the display", async () => {
const el = await mountSlider({
step: FAN_STEP,
@@ -105,13 +81,6 @@ describe("ha-control-slider display rounding", () => {
// the number of speed steps (and keyboard granularity) is preserved.
expect(el.steppedValue(29)).toBeCloseTo(28.5714, 3);
});
it("keeps decimal steps intact unless round-value is set", async () => {
// A temperature-style slider must keep showing halves.
const el = await mountSlider({ step: 0.5, value: 21.5 });
expect(tooltipText(el)).toBe("21.5");
expect(ariaValueNow(el)).toBe("21.5");
});
});
describe("ha-control-slider step bounds", () => {
@@ -157,22 +126,6 @@ describe("ha-control-slider step bounds", () => {
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
});
it("shows and announces the bound, not the overshoot", async () => {
const el = await mountSlider({
...RANGE,
value: 99,
tooltipMode: "always",
});
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
"99"
);
expect(
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow")
).toBe("99");
});
it("keeps paging inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
-52
View File
@@ -1,52 +0,0 @@
import { describe, it, expect } from "vitest";
import { html, nothing, render } from "lit";
import "../../src/components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
HaDataTable,
} from "../../src/components/data-table/ha-data-table";
const columns: DataTableColumnContainer = {
name: { title: "Name", main: true },
area: { title: "Area" },
category: { title: "Category" },
empty_template: { title: "Empty", template: () => nothing },
filled_template: { title: "Filled", template: () => html`filled` },
};
// The narrow row puts every non-main column on a secondary line, joined by dots.
const renderNarrowSecondary = (row: DataTableRowData) => {
const el = document.createElement("ha-data-table") as HaDataTable;
const container = document.createElement("div");
render((el as any)._renderRow(columns, true, row, 0), container);
return container.querySelector(".secondary")!.textContent!.trim();
};
describe("ha-data-table narrow secondary line", () => {
it("does not render separators for empty columns", () => {
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
});
it("separates only the columns that have a value", () => {
expect(
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
).toBe("Kitchen · filled");
});
it("renders a blank secondary line when all secondary columns are empty", () => {
const emptyColumns: DataTableColumnContainer = {
name: { title: "Name", main: true },
area: { title: "Area" },
category: { title: "Category" },
empty_template: { title: "Empty", template: () => nothing },
};
const el = document.createElement("ha-data-table") as HaDataTable;
const container = document.createElement("div");
render(
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
container
);
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
});
});
-96
View File
@@ -35,102 +35,6 @@ const showToast = async (element: HaToast) => {
};
describe("ha-toast", () => {
it("renders its message and bottom offset", async () => {
const element = await mountToast({
labelText: "Configuration saved",
bottomOffset: 24,
});
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
"Configuration saved"
);
expect(
(
element.shadowRoot?.querySelector(".toast") as HTMLElement
).style.getPropertyValue("--ha-toast-bottom-offset")
).toBe("24px");
});
it("renders assigned action and dismiss content", async () => {
toast = document.createElement("ha-toast");
const action = document.createElement("button");
action.slot = "action";
const dismiss = document.createElement("button");
dismiss.slot = "dismiss";
toast.append(action, dismiss);
document.body.append(toast);
await toast.updateComplete;
toast.requestUpdate();
await toast.updateComplete;
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="action"]')
?.assignedElements()
).toEqual([action]);
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
?.assignedElements()
).toEqual([dismiss]);
expect(
toast.shadowRoot
?.querySelector(".actions")
?.classList.contains("has-action")
).toBe(true);
});
it("keeps changing visual text separate from live-region text", async () => {
const element = await mountToast({
labelText: "Updating in 59 seconds",
announceText: "Updating in 60 seconds",
});
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
expect(visibleMessage.closest('[role="status"]')).toBeNull();
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
expect(liveRegion.getAttribute("role")).toBe("status");
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
});
it("falls back to announcing the visible message", async () => {
const element = await mountToast({ labelText: "Configuration saved" });
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.textContent?.trim()
).toBe("Configuration saved");
});
it("activates its live region while shown", async () => {
const element = await mountToast();
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(liveRegion.getAttribute("aria-live")).toBe("off");
await showToast(element);
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it("shows as part of a stack without becoming a popover", async () => {
const element = await mountToast({ stacked: true });
await showToast(element);
expect(
element.shadowRoot!.querySelector(".toast")!.hasAttribute("popover")
).toBe(false);
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
-56
View File
@@ -1,56 +0,0 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import en from "../../src/translations/en.json";
// The state condition summary string. Its verb must agree with how the entity
// list is joined: "and" (match "all") takes a plural verb, "or" (match "any")
// takes a singular verb in English.
const message = (en as any).ui.panel.config.automation.editor.conditions.type
.state.description.full;
const format = (values: Record<string, unknown>) =>
new IntlMessageFormat(message, "en").format(values) as string;
describe("state condition summary grammar", () => {
it("uses a singular verb for a single entity", () => {
expect(
format({
hasAttribute: "false",
numberOfEntities: 1,
matchAny: "false",
entities: "Light",
numberOfStates: 1,
states: "on",
hasDuration: "false",
})
).toBe("If Light is on");
});
it("uses a plural verb for multiple entities matched with all (and)", () => {
expect(
format({
hasAttribute: "false",
numberOfEntities: 2,
matchAny: "false",
entities: "A and B",
numberOfStates: 1,
states: "on",
hasDuration: "false",
})
).toBe("If A and B are on");
});
it("uses a singular verb for multiple entities matched with any (or)", () => {
expect(
format({
hasAttribute: "false",
numberOfEntities: 2,
matchAny: "true",
entities: "A or B",
numberOfStates: 1,
states: "on",
hasDuration: "false",
})
).toBe("If A or B is on");
});
});
@@ -58,10 +58,10 @@ const hass = {
} as unknown as HomeAssistant;
const describeRowTrigger = (trigger: Trigger) =>
describeTrigger(trigger, hass, [], { hideEntities: true });
describeTrigger(trigger, hass, []);
const describeRowCondition = (condition: Condition) =>
describeCondition(condition, hass, [], { hideEntities: true });
describeCondition(condition, hass, []);
describe("describing state triggers and conditions", () => {
const trigger: Trigger = {
@@ -75,16 +75,7 @@ describe("describing state triggers and conditions", () => {
state: "on",
};
it("names the entities by default", () => {
expect(describeTrigger(trigger, hass, [])).toBe(
"When Kitchen light changes to on"
);
expect(describeCondition(condition, hass, [])).toBe(
"If Kitchen light is on"
);
});
it("leaves the entities out when they are rendered as targets", () => {
it("leaves the entities out, they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("State changed to on");
expect(describeRowCondition(condition)).toBe("State is on");
});
@@ -115,16 +106,7 @@ describe("describing numeric state triggers and conditions", () => {
above: 20,
};
it("names the entities by default", () => {
expect(describeTrigger(trigger, hass, [])).toBe(
"When Temperature is above 20"
);
expect(describeCondition(condition, hass, [])).toBe(
"If Temperature is above 20"
);
});
it("leaves the entities out when they are rendered as targets", () => {
it("leaves the entities out, they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("Numeric state crossed above 20");
expect(describeRowCondition(condition)).toBe("Numeric state is above 20");
});
-14
View File
@@ -153,20 +153,6 @@ afterEach(() => {
});
describe("dialog-form mounted nested forms", () => {
it("keeps parent forms mounted while nested", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
const forms = getForms(dialog);
expect(forms).toHaveLength(2);
expect(forms[0].hidden).toBe(true);
expect(forms[1].hidden).toBe(false);
expect(forms[0].hasAttribute("autofocus")).toBe(false);
expect(forms[1].hasAttribute("autofocus")).toBe(true);
});
it("returns to the parent after nested submit", async () => {
const dialog = await openDialog();
const nested = nestedParams({ value: "nested" });
+3 -49
View File
@@ -7,19 +7,7 @@ vi.mock("../../src/components/ha-button", () => ({}));
customElements.define("ha-button", class extends LitElement {});
await import("../../src/layouts/ha-init-page");
const translations: Record<string, string> = {
"ui.init.loading": "Loading translated data",
"ui.init.migration": "Database migration translated\n\nPlease wait",
"ui.init.error.title": "Connection error translated",
"ui.init.error.retry_now": "Retry translated",
};
const localize: LocalizeFunc = (key, values) => {
if (key === "ui.init.error.retrying") {
return `Retry translated ${values?.seconds}`;
}
return translations[key] ?? "";
};
const localize: LocalizeFunc = (key) => key;
let host: HTMLDivElement | undefined;
let element: HaInitPage | undefined;
@@ -42,49 +30,15 @@ afterEach(() => {
});
describe("ha-init-page", () => {
it("renders localized loading and migration states", async () => {
const initPage = await mount();
expect(initPage.shadowRoot!.textContent).toContain(
"Loading translated data"
);
initPage.migration = true;
await initPage.updateComplete;
expect(
initPage.shadowRoot!.querySelector(".migration-text")!.textContent
).toBe("Database migration translated\n\nPlease wait");
});
it("preserves migration paragraph breaks without localization", async () => {
const initPage = await mount({ localize: undefined, migration: true });
expect(
initPage.shadowRoot!.querySelector(".migration-text")!.textContent
).toContain("completed.\n\nThe upgrade");
});
it("renders the localized connection error and countdown", async () => {
const initPage = await mount({ error: true });
expect(initPage.shadowRoot!.textContent).toContain(
"Connection error translated"
);
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 60");
expect(
initPage.shadowRoot!.querySelector("ha-button")!.textContent
).toContain("Retry translated");
});
it("counts down once per second and stops after disconnecting", async () => {
vi.useFakeTimers();
const initPage = await mount({ error: true });
await vi.advanceTimersByTimeAsync(1000);
await initPage.updateComplete;
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 59");
expect((initPage as any)._retryInSeconds).toBe(59);
initPage.remove();
await vi.advanceTimersByTimeAsync(1000);
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 59");
expect((initPage as any)._retryInSeconds).toBe(59);
});
});
@@ -75,16 +75,6 @@ describe("notification toast lifecycle", () => {
await vi.advanceTimersByTimeAsync(20);
await firstShow;
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
"visible"
);
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":60'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
await manager.showDialog({
id: "frontend-update-available",
message: {
@@ -99,13 +89,6 @@ describe("notification toast lifecycle", () => {
duration: -1,
});
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":59'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
const closed = new Promise<void>((resolve) => {
toast.addEventListener("toast-closed", () => resolve(), { once: true });
});
+2 -56
View File
@@ -67,38 +67,16 @@ afterEach(() => {
});
describe("notification-manager", () => {
it("shows a message with the default duration", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("0px");
expect(toast.show).toHaveBeenCalledOnce();
});
it("renders a dismiss button and closes with the dismiss reason", async () => {
it("closes with the dismiss reason", async () => {
const manager = await mountManager();
await manager.showDialog({
message: "Connection lost",
dismissable: true,
duration: -1,
bottomOffset: 16,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.timeoutMs).toBe(-1);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("16px");
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
@@ -141,34 +119,7 @@ describe("notification-manager", () => {
}
);
it("localizes visible and assistive messages with numeric arguments", async () => {
const manager = await mountManager();
await manager.showDialog({
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toContain('"seconds":59');
expect(toast.announceText).toContain('"seconds":60');
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 59 }
);
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 60 }
);
});
it("renders and invokes primary and secondary actions", async () => {
it("invokes primary and secondary actions", async () => {
const manager = await mountManager();
const primary = vi.fn();
const secondary = vi.fn();
@@ -182,11 +133,6 @@ describe("notification-manager", () => {
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent?.trim()).toBe("Cancel");
expect(buttons[0].getAttribute("appearance")).toBe("plain");
expect(buttons[1].textContent?.trim()).toBe("Update now");
expect(buttons[1].getAttribute("appearance")).toBe("filled");
buttons[0].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
@@ -1,71 +0,0 @@
import { expect, test } from "vitest";
import { TZDate } from "@date-fns/tz";
import { isDate } from "../../../src/common/string/is_date";
/**
* These tests verify that all-day event dates are correctly identified
* and can be distinguished from datetime strings. This is critical for
* proper date display in the calendar event detail dialog.
*/
test("isDate correctly identifies date-only strings", () => {
// Valid date-only strings (all-day events)
expect(isDate("2025-10-10")).toBe(true);
expect(isDate("2007-06-28")).toBe(true);
expect(isDate("2025-12-31")).toBe(true);
// DateTime strings should not be identified as dates
expect(isDate("2025-10-10T00:00:00")).toBe(false);
expect(isDate("2025-10-10T14:30:00")).toBe(false);
expect(isDate("2025-10-10T14:30:00Z")).toBe(false);
expect(isDate("2025-10-10T14:30:00+00:00")).toBe(false);
expect(isDate("2025-10-10T14:30:00-08:00")).toBe(false);
});
test("Date parsing for all-day events", () => {
// Verify that date-only strings can be parsed as local dates
const dateStr = "2025-10-10";
const parsed = new Date(dateStr + "T00:00:00");
expect(parsed.getFullYear()).toBe(2025);
expect(parsed.getMonth()).toBe(9); // October (0-indexed)
expect(parsed.getDate()).toBe(10);
});
test("Timed events respect timezone conversion", () => {
// Verify that datetime strings with timezone info are properly converted with TZDate
const datetimeStr = "2025-10-10T14:30:00-07:00"; // 2:30 PM Pacific time
const timeZone = "America/Los_Angeles"; // UTC-7 (PDT) in October
// This should NOT be identified as a date-only string
expect(isDate(datetimeStr)).toBe(false);
// Timed events should use TZDate which respects timezone
const tzDate = new TZDate(datetimeStr, timeZone);
// The date should be October 10, 2:30 PM in LA timezone
expect(tzDate.getFullYear()).toBe(2025);
expect(tzDate.getMonth()).toBe(9); // October (0-indexed)
expect(tzDate.getDate()).toBe(10);
expect(tzDate.getHours()).toBe(14);
expect(tzDate.getMinutes()).toBe(30);
});
test("Timed events display different day due to timezone offset", () => {
// An event at 1 AM UTC on October 10 should display as October 9 in Pacific time
const utcDatetimeStr = "2025-10-10T01:00:00Z";
const timeZone = "America/Los_Angeles"; // UTC-7 (PDT) in October
// This should NOT be identified as a date-only string
expect(isDate(utcDatetimeStr)).toBe(false);
// Parse the UTC datetime in Pacific timezone
const tzDate = new TZDate(utcDatetimeStr, timeZone);
// Due to the -7 hour offset, 1 AM UTC becomes 6 PM on the previous day in Pacific
expect(tzDate.getFullYear()).toBe(2025);
expect(tzDate.getMonth()).toBe(9); // October (0-indexed)
expect(tzDate.getDate()).toBe(9); // Previous day
expect(tzDate.getHours()).toBe(18); // 6 PM
expect(tzDate.getMinutes()).toBe(0);
});
@@ -1,12 +1,5 @@
import { render } from "lit";
import { assert, describe, it } from "vitest";
import "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
import type { HaEnergyPowerConfig } from "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
import type { PowerConfig } from "../../../../src/data/energy";
import {
getPowerHelperEntityId,
type PowerType,
} from "../../../../src/panels/config/energy/dialogs/power-config";
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
describe("getPowerHelperEntityId", () => {
it("returns the helper for an inverted config", () => {
@@ -76,100 +69,3 @@ describe("getPowerHelperEntityId", () => {
);
});
});
// Renders the template directly so the async unit lookup in willUpdate is
// skipped. localize echoes the key back.
const renderPickers = (powerType: PowerType, powerConfig: PowerConfig) => {
const el = document.createElement(
"ha-energy-power-config"
) as HaEnergyPowerConfig;
el.hass = { localize: (key: string) => key } as any;
el.powerType = powerType;
el.powerConfig = powerConfig;
const container = document.createElement("div");
render((el as any).render(), container);
return [...container.querySelectorAll("ha-statistic-picker")].map(
(picker: any) => ({
required: picker.required,
invalid: picker.invalid,
errorMessage: picker.errorMessage,
})
);
};
describe("ha-energy-power-config required power statistic", () => {
it("renders no picker when no power sensor is configured", () => {
assert.lengthOf(renderPickers("none", {}), 0);
});
it("marks an empty standard statistic as required and invalid", () => {
assert.deepEqual(renderPickers("standard", {}), [
{
required: true,
invalid: true,
errorMessage: "ui.common.error_required",
},
]);
});
it("keeps the statistic required but valid once it is set", () => {
const [picker] = renderPickers("standard", { stat_rate: "sensor.power" });
assert.isTrue(picker.required);
assert.isFalse(picker.invalid);
});
it("marks an empty inverted statistic as required and invalid", () => {
const [picker] = renderPickers("inverted", {});
assert.isTrue(picker.required);
assert.isTrue(picker.invalid);
});
it("does not flag the inverted statistic when it is set", () => {
const [picker] = renderPickers("inverted", {
stat_rate_inverted: "sensor.power",
});
assert.isFalse(picker.invalid);
});
it("flags both two sensor statistics while they are empty", () => {
const pickers = renderPickers("two_sensors", {});
assert.lengthOf(pickers, 2);
assert.deepEqual(
pickers.map((p) => p.invalid),
[true, true]
);
});
// The two sensor statistics exclude each other, so they keep their clear
// button — and therefore no required marker — to stay swappable.
it("does not mark the two sensor statistics as required", () => {
const pickers = renderPickers("two_sensors", {});
assert.deepEqual(
pickers.map((p) => p.required),
[false, false]
);
});
it("flags only the statistic that is still missing", () => {
const pickers = renderPickers("two_sensors", {
stat_rate_from: "sensor.power_from",
});
assert.deepEqual(
pickers.map((p) => p.invalid),
[false, true]
);
});
it("clears both flags once the two sensor pair is complete", () => {
const pickers = renderPickers("two_sensors", {
stat_rate_from: "sensor.power_from",
stat_rate_to: "sensor.power_to",
});
assert.deepEqual(
pickers.map((p) => p.invalid),
[false, false]
);
});
});
@@ -1,147 +0,0 @@
import { describe, it, expect } from "vitest";
import type { MatterLockInfo } from "../../../../../src/data/matter-lock";
/**
* These tests verify the display logic for the lock management dialog,
* ensuring the correct alert is shown based on lock capabilities.
*/
type AlertState =
| "no_user_management"
| "no_credential_types_supported"
| "pin_not_supported"
| "full_support";
/**
* Mirrors the branching logic in dialog-matter-lock-manage.ts render()
* and _renderUsers() to determine which alert (if any) to display.
*/
function getAlertState(lockInfo: MatterLockInfo | undefined): AlertState {
if (lockInfo && !lockInfo.supports_user_management) {
return "no_user_management";
}
const hasNoManageableCredentials =
!lockInfo?.supported_credential_types?.length;
if (hasNoManageableCredentials) {
return "no_credential_types_supported";
}
const supportsPinCredential =
lockInfo?.supported_credential_types?.includes("pin") ?? false;
if (!supportsPinCredential) {
return "pin_not_supported";
}
return "full_support";
}
describe("dialog-matter-lock-manage alert logic", () => {
it("shows no_user_management when lock does not support user management", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: false,
supported_credential_types: [],
max_users: null,
max_pin_users: null,
max_rfid_users: null,
max_credentials_per_user: null,
min_pin_length: null,
max_pin_length: null,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("no_user_management");
});
it("shows no_user_management even if credential types are listed", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: false,
supported_credential_types: ["pin"],
max_users: null,
max_pin_users: null,
max_rfid_users: null,
max_credentials_per_user: null,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("no_user_management");
});
it("shows no_credential_types_supported when user management is supported but no credential types", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: [],
max_users: 10,
max_pin_users: null,
max_rfid_users: null,
max_credentials_per_user: null,
min_pin_length: null,
max_pin_length: null,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("no_credential_types_supported");
});
it("shows pin_not_supported when user management is supported with non-pin credentials only", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["rfid"],
max_users: 10,
max_pin_users: null,
max_rfid_users: 5,
max_credentials_per_user: 3,
min_pin_length: null,
max_pin_length: null,
min_rfid_length: 4,
max_rfid_length: 8,
};
expect(getAlertState(lockInfo)).toBe("pin_not_supported");
});
it("shows full_support when user management and pin are both supported", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: null,
max_credentials_per_user: 5,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("full_support");
});
it("shows full_support when both pin and rfid are supported", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin", "rfid"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: 5,
max_credentials_per_user: 5,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: 4,
max_rfid_length: 8,
};
expect(getAlertState(lockInfo)).toBe("full_support");
});
it("handles undefined lockInfo as no_credential_types_supported", () => {
expect(getAlertState(undefined)).toBe("no_credential_types_supported");
});
});
@@ -125,12 +125,6 @@ const nextTask = () =>
setTimeout(resolve, 0);
});
const rows = (el: HTMLElement) =>
Array.from(el.shadowRoot!.querySelectorAll("ha-list-item"));
const rowFor = (el: HTMLElement, name: string) =>
rows(el).find((row) => row.textContent?.includes(name));
describe("ha-config-section-storage per-mount usage", () => {
let usageCalls: Record<string, Deferred<any>>;
@@ -185,57 +179,4 @@ describe("ha-config-section-storage per-mount usage", () => {
expect(asked).toEqual(["alpha", "beta"]);
expect(asked).not.toContain("broken");
});
it("renders the rows before any usage arrives", async () => {
const el = await render();
expect(rows(el)).toHaveLength(3);
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).not.toBeNull();
});
it("fills in each row as its own request settles", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
await nextTask();
await (el as any).updateComplete;
expect(rowFor(el, "alpha")!.textContent).toContain(
"500 Bytes of 1000 Bytes used"
);
// beta has not answered yet, so it must still be pending, not blanked.
expect(rowFor(el, "beta")!.querySelector("ha-spinner")).not.toBeNull();
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).toBeNull();
});
it("leaves a row without usage when its request fails, and keeps the others", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
usageCalls.beta.reject(new Error("dead server"));
await nextTask();
await (el as any).updateComplete;
const beta = rowFor(el, "beta")!;
expect(beta.querySelector("ha-spinner")).toBeNull();
expect(beta.querySelector("ha-bar")).toBeNull();
expect(beta.textContent).not.toContain("used");
expect(rowFor(el, "alpha")!.querySelector("ha-bar")).not.toBeNull();
});
it("never shows usage for a mount that is not active", async () => {
const el = await render();
const broken = rowFor(el, "broken")!;
expect(broken.querySelector("ha-spinner")).toBeNull();
expect(broken.querySelector("ha-bar")).toBeNull();
});
});
@@ -75,15 +75,6 @@ const isEnabled = (
) => !control(state, action, attributes).disabled;
describe("media player playback default controls", () => {
it("lead with a power toggle, then previous, play/pause and next", () => {
expect(MEDIA_PLAYER_DEFAULT_CONTROLS).toEqual([
"power",
"media_previous_track",
"media_play_pause",
"media_next_track",
]);
});
it("renders play when idle, pause while playing", () => {
expect(
controlsFor("idle", MEDIA_PLAYER_DEFAULT_CONTROLS).map((b) => b.action)
@@ -1,37 +0,0 @@
import { describe, it, expect } from "vitest";
import type { Node } from "../../../../../../src/components/chart/ha-sankey-chart";
describe("hui-power-sankey-card", () => {
describe("node click handling", () => {
it("should identify device nodes as clickable via entityId", () => {
const nodes: Node[] = [
{ id: "solar", value: 1000, index: 0, label: "Solar" },
{ id: "home", value: 1500, index: 1, label: "Home" },
{
id: "sensor.device1",
value: 200,
index: 4,
label: "Device 1",
entityId: "sensor.device1",
},
];
const clickableNodes = nodes.filter((n) => n.entityId);
expect(clickableNodes).toHaveLength(1);
expect(clickableNodes[0].entityId).toBe("sensor.device1");
});
it("should not make source/area/floor nodes clickable", () => {
const nodes: Node[] = [
{ id: "solar", value: 1000, index: 0 },
{ id: "grid", value: 500, index: 0 },
{ id: "home", value: 1500, index: 1 },
{ id: "floor_1", value: 800, index: 2 },
{ id: "area_kitchen", value: 400, index: 3 },
];
const clickableNodes = nodes.filter((n) => n.entityId);
expect(clickableNodes).toHaveLength(0);
});
});
});
@@ -22,13 +22,9 @@ describe("error badge factories", () => {
type: "error",
error: "test error",
}) as HuiErrorBadge;
document.body.append(element);
await element.updateComplete;
expect(element.localName).toBe("hui-error-badge");
expect(element.shadowRoot?.textContent).toContain("test error");
element.remove();
expect((element as any)._config.error).toBe("test error");
});
it("creates a configured error heading badge on the first call", async () => {
@@ -36,12 +32,8 @@ describe("error badge factories", () => {
type: "error",
error: "test heading error",
}) as HuiErrorHeadingBadge;
document.body.append(element);
await element.updateComplete;
expect(element.localName).toBe("hui-error-heading-badge");
expect(element.shadowRoot?.textContent).toContain("test heading error");
element.remove();
expect((element as any)._config.error).toBe("test heading error");
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import type { SecurityFrontendSystemData } from "../../../../src/data/frontend";
import type { EditSecurityDialogParams } from "../../../../src/panels/security/dialogs/show-dialog-edit-security";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import "../../../../src/panels/security/dialogs/dialog-edit-security";
import { createMockHass } from "../../../fixtures/hass";
interface TestEditSecurityDialog extends HTMLElement {
params: EditSecurityDialogParams;
_i18n: HomeAssistantInternationalization;
isDirtyState: boolean;
connectedCallback(): void;
disconnectedCallback(): void;
performUpdate(): void;
}
const alertEntitiesChanged = (
dialog: TestEditSecurityDialog,
ev: CustomEvent
) =>
(
dialog as unknown as Record<
"_alertEntitiesChanged",
(event: CustomEvent) => void
>
)["_alertEntitiesChanged"](ev);
describe("dialog-edit-security", () => {
const createDialog = (config: SecurityFrontendSystemData = {}) => {
const hass = createMockHass();
const dialog = document.createElement(
"dialog-edit-security"
) as unknown as TestEditSecurityDialog;
dialog._i18n = hass;
dialog.params = {
config,
saveConfig: vi.fn(),
};
dialog.performUpdate = vi.fn();
dialog.connectedCallback();
return dialog;
};
it("becomes clean after nested configuration is restored", () => {
const alertEntities = [
{ entity: "binary_sensor.window", severity: "warning" as const },
];
const dialog = createDialog({ alert_entities: alertEntities });
alertEntitiesChanged(
dialog,
new CustomEvent("value-changed", {
detail: {
value: [{ ...alertEntities[0], severity: "alert" }],
},
})
);
expect(dialog.isDirtyState).toBe(true);
alertEntitiesChanged(
dialog,
new CustomEvent("value-changed", {
detail: { value: [{ ...alertEntities[0] }] },
})
);
expect(dialog.isDirtyState).toBe(false);
dialog.disconnectedCallback();
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
computeDefaultSecurityAlertVisibility,
computeSecurityAlertCardConfig,
} from "../../../../src/panels/security/strategies/security-alerts";
import { createMockEntityState } from "../../../fixtures/hass";
describe("computeDefaultSecurityAlertVisibility", () => {
it.each([
["alarm_control_panel.house", { state: "triggered" }],
["binary_sensor.leak", { state: "on" }],
["cover.garage_door", { state: "open" }],
[
"lock.front_door",
{
state: ["jammed", "unlocked", "open"],
},
],
])("uses the active state for %s", (entityId, stateCondition) => {
expect(computeDefaultSecurityAlertVisibility(entityId)).toEqual([
{
condition: "state",
entity: entityId,
...stateCondition,
},
]);
});
});
describe("computeSecurityAlertCardConfig", () => {
it("maps alert severity to a red alert card", () => {
expect(
computeSecurityAlertCardConfig(undefined, {
entity: "binary_sensor.smoke",
severity: "alert",
})
).toEqual({
type: "alert",
entity: "binary_sensor.smoke",
color: "red",
visibility: [
{
condition: "state",
entity: "binary_sensor.smoke",
state: "on",
},
],
});
});
it("uses the entity device class for the default severity", () => {
const stateObj = createMockEntityState("binary_sensor.smoke", "off", {
device_class: "smoke",
});
expect(
computeSecurityAlertCardConfig(stateObj, {
entity: "binary_sensor.smoke",
}).color
).toBe("red");
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { SecurityViewStrategy } from "../../../../src/panels/security/strategies/security-view-strategy";
import { createMockHass } from "../../../fixtures/hass";
describe("security-view-strategy", () => {
it("renders active alerts as individual cards in a visible section", async () => {
const hass = createMockHass();
hass.config = { ...hass.config, components: [] };
const view = await SecurityViewStrategy.generate(
{
type: "security",
alert_entities: [{ entity: "binary_sensor.window" }],
},
hass
);
const alertSection = view.sidebar?.sections?.[0];
expect(alertSection?.visibility).toEqual([
{
condition: "or",
conditions: [
{
condition: "and",
conditions: [
{
condition: "state",
entity: "binary_sensor.window",
state: "on",
},
],
},
],
},
]);
expect(alertSection?.cards).toEqual([
{
type: "heading",
heading: "ui.panel.lovelace.strategy.security.active_alerts",
heading_style: "title",
},
{
type: "alert",
entity: "binary_sensor.window",
color: "amber",
visibility: [
{
condition: "state",
entity: "binary_sensor.window",
state: "on",
},
],
grid_options: { columns: 12 },
},
]);
});
});
-6
View File
@@ -51,12 +51,6 @@ describe("fileDownload", () => {
expect(removeChildSpy).toHaveBeenCalledWith(createdElement);
});
it("defaults filename to empty string", async () => {
await loadFileDownload();
fileDownload("https://example.com/file.json");
expect(createdElement.download).toBe("");
});
it("does not revoke non-blob URLs", async () => {
await loadFileDownload();
fileDownload("https://example.com/file.json", "file.json");
+5 -5
View File
@@ -2283,14 +2283,14 @@ __metadata:
languageName: node
linkType: hard
"@bundle-stats/plugin-webpack-filter@npm:4.22.2":
version: 4.22.2
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.22.2"
"@bundle-stats/plugin-webpack-filter@npm:4.22.3":
version: 4.22.3
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.22.3"
dependencies:
tslib: "npm:2.8.1"
peerDependencies:
core-js: ^3.0.0
checksum: 10/29f837793a9b95e265cf4a98ef48b23808097c9d007448f5ea81732981f8f0c11b5aa443d6c21219e8f4c516ade12419198f8ea12a2ced04f1090a3e99bb41f0
checksum: 10/bbc2c12a7b98de0a8548d3ec5de2739e08743a13edcebb3700413cb418faa11e5991fffbff22807e68739399aef633a41745d4b5889a87938fab89e4a4459292
languageName: node
linkType: hard
@@ -9975,7 +9975,7 @@ __metadata:
"@babel/preset-env": "npm:8.0.2"
"@babel/runtime": "npm:8.0.0"
"@braintree/sanitize-url": "npm:7.1.2"
"@bundle-stats/plugin-webpack-filter": "npm:4.22.2"
"@bundle-stats/plugin-webpack-filter": "npm:4.22.3"
"@codemirror/autocomplete": "npm:6.20.3"
"@codemirror/commands": "npm:6.11.0"
"@codemirror/lang-jinja": "npm:6.0.1"