Compare commits

...
6 changed files with 531 additions and 66 deletions
+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,
@@ -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,
@@ -35,17 +37,20 @@ import type {
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;
@@ -256,16 +261,25 @@ 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",
@@ -454,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,
@@ -500,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 {
@@ -64,6 +64,10 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
}
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(
+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);
});
});