Compare commits

...
Author SHA1 Message Date
Aidan Timson 543cfad738 Render Home alerts as individual cards 2026-08-28 08:48:45 +01:00
Aidan Timson 328eb62ab1 Fix Home alert fallback state 2026-08-28 08:48:29 +01:00
Aidan Timson c388068782 Fix Home security alert state handling 2026-08-28 08:48:29 +01:00
Aidan Timson 2b8c57481d Refresh Home security alerts 2026-08-28 08:48:11 +01:00
Aidan Timson cfa0d62afc Show security alerts on Home 2026-08-28 08:48:11 +01:00
Maarten LakerveldandGitHub b1d8a21aaa Don't close drawer when a nested tooltip hides (#53809)
wa-after-hide from a tooltip inside the drawer (e.g. the exact time
tooltip on a persistent notification) bubbled to the wa-drawer listener
and was treated as the drawer closing. Only handle the drawer's own event.

Fixes #52901
2026-08-28 08:45:28 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cde2e66229 Update dependency typescript-eslint to v8.68.0 (#53848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 06:45:08 +02:00
Paulus SchoutsenandGitHub 71d5500ed8 Render camera favorites as camera tiles (#53814) 2026-08-28 00:05:02 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
462321a3c8 Update dependency eslint to v10.9.1 (#53846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
afc0c36dd9 Update dependency marked to v18.0.11 (#53847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:43 +02:00
11 changed files with 664 additions and 184 deletions
+3 -3
View File
@@ -117,7 +117,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -179,7 +179,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.0",
"eslint": "10.9.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -216,7 +216,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.68.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
+4
View File
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
+60 -6
View File
@@ -7,6 +7,7 @@ import { styleMap } from "lit/directives/style-map";
import { atLeastVersion } from "../../common/config/version";
import { navigate } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-svg-icon";
import { updateAreaRegistryEntry } from "../../data/area/area_registry";
@@ -14,9 +15,12 @@ import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
subscribeFrontendSystemData,
type HomeFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
import type { HomeAssistant, PanelInfo, Route } from "../../types";
import { showToast } from "../../util/toast";
@@ -35,7 +39,7 @@ import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
import { hasLegacyOverviewPanel } from "../../data/panel";
@customElement("ha-panel-home")
class PanelHome extends LitElement {
class PanelHome extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -48,12 +52,36 @@ class PanelHome extends LitElement {
@state() private _config: FrontendSystemData["home"] = {};
@state() private _securityConfig: SecurityFrontendSystemData = {};
@state() private _extraActionItems?: ExtraActionItem[];
@query(".banner") private _banner?: HTMLElement;
private _loadConfigPromise?: Promise<void>;
private _securityConfigRevision = 0;
public hassSubscribe() {
return [
subscribeFrontendSystemData(
this.hass.connection,
"security",
({ value }) => {
this._securityConfigRevision++;
const config = value || {};
if (deepEqual(config, this._securityConfig)) {
return;
}
this._securityConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
}
),
];
}
private get _showBanner(): boolean {
// Don't show if already dismissed
if (this._config.welcome_banner_dismissed) {
@@ -150,16 +178,41 @@ class PanelHome extends LitElement {
}
private async _loadConfig() {
try {
const [_, data] = await Promise.all([
const securityConfigRevision = this._securityConfigRevision;
const [translationsResult, homeResult, securityResult] =
await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
this._config = data || {};
} catch (err) {
if (translationsResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error("Failed to load favorites:", err);
console.error(
"Failed to load Lovelace translations:",
translationsResult.reason
);
}
if (homeResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error("Failed to load home configuration:", homeResult.reason);
this._config = {};
} else {
this._config = homeResult.value || {};
}
if (securityResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load security configuration:",
securityResult.reason
);
if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = {};
}
} else if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = securityResult.value || {};
}
}
@@ -343,6 +396,7 @@ class PanelHome extends LitElement {
return {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
favorite_entities: this._config.favorite_entities,
home_panel: true,
hide_welcome_message: this._config.hide_welcome_message,
@@ -0,0 +1,32 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -1,6 +1,8 @@
import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
import type { ShortcutItem } from "../../../../data/home_shortcuts";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
@@ -14,11 +16,11 @@ import {
} from "./helpers/home-summaries";
import type { HomeAreaViewStrategyConfig } from "./home-area-view-strategy";
import type { HomeOtherDevicesViewStrategyConfig } from "./home-other-devices-view-strategy";
import type { ShortcutItem } from "../../../../data/home_shortcuts";
import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strategy";
export interface HomeDashboardStrategyConfig {
type: "home";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -103,6 +105,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
path: "overview",
strategy: {
type: "home-overview",
alert_entities: config.alert_entities,
favorite_entities: config.favorite_entities,
home_panel: config.home_panel,
hide_welcome_message: config.hide_welcome_message,
@@ -11,6 +11,7 @@ import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
import type { EnergyPreferences } from "../../../../data/energy";
import { getEnergyPreferences } from "../../../../data/energy";
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
LovelaceSectionConfig,
@@ -24,6 +25,7 @@ import type { HomeAssistant } from "../../../../types";
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
import type {
AreaCardConfig,
ConditionalCardConfig,
DiscoveredDevicesCardConfig,
EmptyStateCardConfig,
HeadingCardConfig,
@@ -34,17 +36,21 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "../../../security/strategies/security-alerts";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
} from "../helpers/view-columns-conditions";
import type { LovelaceStrategyDependency } from "../types";
import type { CommonControlsSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
import { generateLovelaceSectionStrategy } from "../get-strategy";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
export interface HomeOverviewViewStrategyConfig {
type: "home-overview";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -255,31 +261,32 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
let favoritesSection: LovelaceSectionRawConfig | undefined;
if (!config.hide_suggested_entities) {
favoritesSection = {
strategy: {
type: "common-controls",
limit: maxCommonControls,
include_entities: favoriteEntities,
hide_empty: true,
heading: favoritesHeadingCard,
} satisfies CommonControlsSectionStrategyConfig,
column_span: maxColumns,
} satisfies LovelaceStrategySectionConfig;
const generatedFavoritesSection = await generateLovelaceSectionStrategy(
{
strategy: {
type: "common-controls",
limit: maxCommonControls,
include_entities: favoriteEntities,
hide_empty: true,
heading: favoritesHeadingCard,
} satisfies CommonControlsSectionStrategyConfig,
column_span: maxColumns,
} satisfies LovelaceStrategySectionConfig,
hass
);
if (!generatedFavoritesSection.disabled) {
favoritesSection = {
...generatedFavoritesSection,
column_span: maxColumns,
};
}
} else if (favoriteEntities.length > 0) {
favoritesSection = {
type: "grid",
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
};
}
@@ -461,6 +468,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
}
const hasVisibleSummaryCards = summaryCards.some(
(card) => !("hide_empty" in card && card.hide_empty)
);
// Build summary cards for sidebar (full width: columns 12)
const sidebarSummaryCards = summaryCards.map((card) => ({
...card,
@@ -507,62 +518,127 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
: undefined;
const alertCards = config.alert_entities?.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
const alertsSection: LovelaceSectionConfig | undefined = alertCards?.length
? {
type: "grid",
column_span: maxColumns,
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",
grid_options: { columns: "full" },
},
...alertCards,
],
}
: undefined;
const emptyStateCard = {
type: "empty-state",
icon: "mdi:home-assistant",
content_only: true,
title: hass.localize("ui.panel.lovelace.strategy.home.welcome_title"),
content: hass.localize("ui.panel.lovelace.strategy.home.welcome_content"),
...(config.home_panel && hass.user?.is_admin
? {
buttons: [
{
icon: "mdi:plus",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_add_device"
),
appearance: "filled" as const,
variant: "brand" as const,
tap_action: {
action: "fire-dom-event" as const,
home_panel: {
type: "add_integration",
},
},
},
{
icon: "mdi:home-edit",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
),
appearance: "plain" as const,
variant: "brand" as const,
tap_action: {
action: "navigate" as const,
navigation_path: "/config/areas/dashboard",
},
},
],
}
: {}),
} as EmptyStateCardConfig;
// No sections, show empty state
if (floorsSections.length === 0) {
if (floorsSections.length === 0 && !alertsSection) {
return {
type: "panel",
cards: [
{
type: "empty-state",
icon: "mdi:home-assistant",
content_only: true,
title: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_title"
),
content: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_content"
),
...(config.home_panel && hass.user?.is_admin
? {
buttons: [
{
icon: "mdi:plus",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_add_device"
),
appearance: "filled",
variant: "brand",
tap_action: {
action: "fire-dom-event",
home_panel: {
type: "add_integration",
},
},
},
{
icon: "mdi:home-edit",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
),
appearance: "plain",
variant: "brand",
tap_action: {
action: "navigate",
navigation_path: "/config/areas/dashboard",
},
},
],
}
: {}),
} as EmptyStateCardConfig,
],
cards: [emptyStateCard],
};
}
const emptyStateSection: LovelaceSectionConfig | undefined =
floorsSections.length === 0 &&
!favoritesSection &&
!hasVisibleSummaryCards &&
alertCards?.length
? {
type: "grid",
column_span: maxColumns,
cards: [
{
type: "conditional",
conditions: [
{
condition: "not",
conditions: [
{
condition: "or",
conditions: alertCards.map((alertCard) => ({
condition: "and",
conditions: alertCard.visibility!,
})),
},
],
},
],
card: emptyStateCard,
} satisfies ConditionalCardConfig,
],
}
: undefined;
const sections = (
[favoritesSection, mobileSummarySection, ...floorsSections] satisfies (
LovelaceSectionRawConfig | undefined
)[]
[
alertsSection,
emptyStateSection,
favoritesSection,
mobileSummarySection,
...floorsSections,
] satisfies (LovelaceSectionRawConfig | undefined)[]
).filter(Boolean) as LovelaceSectionRawConfig[];
return {
@@ -4,8 +4,9 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { HeadingCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -25,13 +26,6 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -63,11 +57,17 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
return section;
}
if (!isComponentLoaded(hass.config, "usage_prediction")) {
if (includedEntities.length > 0) {
section.cards!.push(...includedEntities.map(toTileCard));
return section;
}
section.cards!.push({
type: "markdown",
content: hass.localize(
@@ -110,7 +110,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(toTileCard));
section.cards!.push(...entities.map(computeFavoriteCardConfig));
return section;
}
}
@@ -18,11 +18,9 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -186,15 +184,7 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
+139
View File
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Connection } from "home-assistant-js-websocket";
import type {
HomeFrontendSystemData,
SecurityFrontendSystemData,
} from "../../../src/data/frontend";
import type { HomeAssistant } from "../../../src/types";
import "../../../src/panels/home/ha-panel-home";
import { createMockHass } from "../../fixtures/hass";
vi.hoisted(() => {
Object.assign(globalThis, {
__STATIC_PATH__: "/",
__HASS_URL__: "",
__BUILD__: "modern",
__VERSION__: "test",
__BACKWARDS_COMPAT__: false,
__SUPERVISOR__: false,
__NAMESPACE__: "frontend",
});
});
type TestPanelHome = HTMLElement & {
hass: HomeAssistant;
hassSubscribe(): unknown[];
_config: HomeFrontendSystemData;
_securityConfig: SecurityFrontendSystemData;
} & Record<"_loadConfig", () => Promise<void>>;
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
};
describe("ha-panel-home configuration loading", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not overwrite a newer security subscription update", async () => {
const translations = deferred<HomeAssistant["localize"] | undefined>();
let securityChanged:
| ((data: { value: SecurityFrontendSystemData | null }) => void)
| undefined;
const hass = createMockHass();
hass.loadFragmentTranslation = () => translations.promise;
hass.connection = {
sendMessagePromise: vi.fn(async (message: { key: string }) => ({
value:
message.key === "home"
? {}
: { alert_entities: [{ entity: "binary_sensor.old" }] },
})),
subscribeMessage: vi.fn(
(
callback: (data: { value: SecurityFrontendSystemData | null }) => void
) => {
securityChanged = callback;
return Promise.resolve(() => undefined);
}
),
} as unknown as Connection;
const element = document.createElement(
"ha-panel-home"
) as unknown as TestPanelHome;
element.hass = hass;
element.hassSubscribe();
const loading = element._loadConfig();
securityChanged!({
value: { alert_entities: [{ entity: "binary_sensor.new" }] },
});
translations.resolve(undefined);
await loading;
expect(element._securityConfig.alert_entities).toEqual([
{ entity: "binary_sensor.new" },
]);
});
it("preserves loaded Home config when security loading fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const homeConfig: HomeFrontendSystemData = {
favorite_entities: ["light.kitchen"],
hide_suggested_entities: true,
shortcuts: [{ type: "custom", path: "/test", label: "Test" }],
};
const hass = createMockHass();
hass.loadFragmentTranslation = async () => undefined;
hass.connection = {
sendMessagePromise: vi.fn(async (message: { key: string }) => {
if (message.key === "security") {
throw new Error("Security unavailable");
}
return { value: homeConfig };
}),
} as unknown as Connection;
const element = document.createElement(
"ha-panel-home"
) as unknown as TestPanelHome;
element.hass = hass;
element._securityConfig = {
alert_entities: [{ entity: "binary_sensor.old" }],
};
await element._loadConfig();
expect(element._config).toEqual(homeConfig);
expect(element._securityConfig).toEqual({});
});
it("preserves loaded Home config when translations fail", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const homeConfig: HomeFrontendSystemData = {
favorite_entities: ["light.kitchen"],
};
const hass = createMockHass();
hass.loadFragmentTranslation = async () => {
throw new Error("Translations unavailable");
};
hass.connection = {
sendMessagePromise: vi.fn(async (message: { key: string }) => ({
value: message.key === "home" ? homeConfig : {},
})),
} as unknown as Connection;
const element = document.createElement(
"ha-panel-home"
) as unknown as TestPanelHome;
element.hass = hass;
await element._loadConfig();
expect(element._config).toEqual(homeConfig);
});
});
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from "vitest";
import type { LovelaceSectionConfig } from "../../../../../src/data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../../src/data/lovelace/config/view";
import { checkConditionsMet } from "../../../../../src/panels/lovelace/common/validate-condition";
import type { ConditionalCardConfig } from "../../../../../src/panels/lovelace/cards/types";
import { HomeOverviewViewStrategy } from "../../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import type { HomeAssistant } from "../../../../../src/types";
import {
createMockEntityState,
createMockHass,
} from "../../../../fixtures/hass";
const createHass = (state: string): HomeAssistant => {
const hass = createMockHass({
"binary_sensor.front_door": createMockEntityState(
"binary_sensor.front_door",
state,
{ device_class: "door" }
),
});
return {
...hass,
config: { ...hass.config, components: [] },
panels: {},
};
};
const sections = (view: LovelaceViewConfig): LovelaceSectionConfig[] => {
expect(view.type).toBe("sections");
return view.sections as LovelaceSectionConfig[];
};
describe("HomeOverviewViewStrategy security alerts", () => {
it("maps configured severity to the security alert card config", async () => {
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [
{ entity: "binary_sensor.front_door", severity: "alert" },
],
},
createHass("on")
);
const alertSection = sections(view)[0];
expect(alertSection.cards).toEqual([
{
type: "heading",
heading: "ui.panel.lovelace.strategy.security.active_alerts",
heading_style: "title",
grid_options: { columns: "full" },
},
{
type: "alert",
entity: "binary_sensor.front_door",
color: "red",
visibility: [
{
condition: "state",
entity: "binary_sensor.front_door",
state: "on",
},
],
},
]);
});
it("reactively shows the area-less empty state only without active alerts", async () => {
const inactiveHass = createHass("off");
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [{ entity: "binary_sensor.front_door" }],
hide_suggested_entities: true,
},
inactiveHass
);
const emptyStateCard = sections(view)[1]
?.cards?.[0] as ConditionalCardConfig;
expect(
checkConditionsMet(emptyStateCard.conditions, inactiveHass, {})
).toBe(true);
expect(
checkConditionsMet(emptyStateCard.conditions, createHass("on"), {})
).toBe(false);
expect(emptyStateCard.card.type).toBe("empty-state");
});
it("shows the area-less empty state when prediction returns no controls", async () => {
const hass = createHass("off");
hass.config = { ...hass.config, components: ["usage_prediction"] };
hass.callWS = vi.fn().mockResolvedValue({ entities: [] });
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [{ entity: "binary_sensor.front_door" }],
},
hass
);
expect(
sections(view).some((section) =>
section.cards?.some((card) => card.type === "conditional")
)
).toBe(true);
});
it("does not show the area-less empty state with a predicted control", async () => {
const hass = createHass("off");
hass.states["light.kitchen"] = createMockEntityState("light.kitchen", "on");
hass.config = { ...hass.config, components: ["usage_prediction"] };
hass.callWS = vi.fn().mockResolvedValue({ entities: ["light.kitchen"] });
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [{ entity: "binary_sensor.front_door" }],
},
hass
);
expect(
sections(view).some((section) =>
section.cards?.some((card) => card.type === "conditional")
)
).toBe(false);
});
it("keeps the Home view when prediction loading fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const hass = createHass("off");
hass.config = { ...hass.config, components: ["usage_prediction"] };
hass.callWS = vi
.fn()
.mockRejectedValue(new Error("Prediction unavailable"));
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [{ entity: "binary_sensor.front_door" }],
},
hass
);
const alertCard = sections(view)[0]?.cards?.[1];
expect(alertCard?.type).toBe("alert");
});
it("does not show the area-less empty state with configured favorites", async () => {
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [{ entity: "binary_sensor.front_door" }],
favorite_entities: ["binary_sensor.front_door"],
},
createHass("off")
);
expect(
sections(view).some((section) =>
section.cards?.some((card) => card.type === "conditional")
)
).toBe(false);
});
it("does not show the area-less empty state with a visible shortcut", async () => {
const view = await HomeOverviewViewStrategy.generate(
{
type: "home-overview",
alert_entities: [{ entity: "binary_sensor.front_door" }],
hide_suggested_entities: true,
shortcuts: [{ type: "custom", path: "/test", label: "Test" }],
},
createHass("off")
);
expect(
sections(view).some((section) =>
section.cards?.some((card) => card.type === "conditional")
)
).toBe(false);
});
});
+83 -83
View File
@@ -5951,105 +5951,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
"@typescript-eslint/eslint-plugin@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/type-utils": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.67.0
"@typescript-eslint/parser": ^8.68.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
"@typescript-eslint/parser@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/parser@npm:8.68.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
"@typescript-eslint/project-service@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/project-service@npm:8.68.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
"@typescript-eslint/types": "npm:^8.68.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
"@typescript-eslint/scope-manager@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
"@typescript-eslint/type-utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/types@npm:8.68.0"
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
"@typescript-eslint/typescript-estree@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/project-service": "npm:8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -6057,32 +6057,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
"@typescript-eslint/utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/utils@npm:8.68.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
"@typescript-eslint/visitor-keys@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.68.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
languageName: node
linkType: hard
@@ -9097,9 +9097,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
"eslint@npm:10.9.1":
version: 10.9.1
resolution: "eslint@npm:10.9.1"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9138,7 +9138,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
languageName: node
linkType: hard
@@ -10206,7 +10206,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.9.0"
eslint: "npm:10.9.1"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10246,7 +10246,7 @@ __metadata:
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
maplibre-gl: "npm:5.24.0"
marked: "npm:18.0.10"
marked: "npm:18.0.11"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.2.0"
node-vibrant: "npm:4.0.4"
@@ -10269,7 +10269,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
typescript-eslint: "npm:8.68.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
@@ -11944,12 +11944,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
"marked@npm:18.0.11":
version: 18.0.11
resolution: "marked@npm:18.0.11"
bin:
marked: bin/marked.js
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
languageName: node
linkType: hard
@@ -15232,18 +15232,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
"typescript-eslint@npm:8.68.0":
version: 8.68.0
resolution: "typescript-eslint@npm:8.68.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
"@typescript-eslint/parser": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
languageName: node
linkType: hard