Skip to content

Commit 4460c07

Browse files
committed
Fix Home security alert state handling
1 parent 543c436 commit 4460c07

4 files changed

Lines changed: 362 additions & 67 deletions

File tree

src/panels/home/ha-panel-home.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@ class PanelHome extends SubscribeMixin(LitElement) {
6060

6161
private _loadConfigPromise?: Promise<void>;
6262

63+
private _securityConfigRevision = 0;
64+
6365
public hassSubscribe() {
6466
return [
6567
subscribeFrontendSystemData(
6668
this.hass.connection,
6769
"security",
6870
({ value }) => {
71+
this._securityConfigRevision++;
6972
const config = value || {};
7073
if (deepEqual(config, this._securityConfig)) {
7174
return;
@@ -175,19 +178,40 @@ class PanelHome extends SubscribeMixin(LitElement) {
175178
}
176179

177180
private async _loadConfig() {
178-
try {
179-
const [_, homeData, securityData] = await Promise.all([
181+
const securityConfigRevision = this._securityConfigRevision;
182+
const [translationsResult, homeResult, securityResult] =
183+
await Promise.allSettled([
180184
this.hass.loadFragmentTranslation("lovelace"),
181185
fetchFrontendSystemData(this.hass.connection, "home"),
182186
fetchFrontendSystemData(this.hass.connection, "security"),
183187
]);
184-
this._config = homeData || {};
185-
this._securityConfig = securityData || {};
186-
} catch (err) {
188+
189+
if (translationsResult.status === "rejected") {
190+
// eslint-disable-next-line no-console
191+
console.error(
192+
"Failed to load home configuration:",
193+
translationsResult.reason
194+
);
195+
this._config = {};
196+
} else if (homeResult.status === "rejected") {
187197
// eslint-disable-next-line no-console
188-
console.error("Failed to load configuration:", err);
198+
console.error("Failed to load home configuration:", homeResult.reason);
189199
this._config = {};
190-
this._securityConfig = {};
200+
} else {
201+
this._config = homeResult.value || {};
202+
}
203+
204+
if (securityResult.status === "rejected") {
205+
// eslint-disable-next-line no-console
206+
console.error(
207+
"Failed to load security configuration:",
208+
securityResult.reason
209+
);
210+
if (securityConfigRevision === this._securityConfigRevision) {
211+
this._securityConfig = {};
212+
}
213+
} else if (securityConfigRevision === this._securityConfigRevision) {
214+
this._securityConfig = securityResult.value || {};
191215
}
192216
}
193217

src/panels/lovelace/strategies/home/home-overview-view-strategy.ts

Lines changed: 106 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { resolveShortcutItems } from "../../../../data/home_shortcuts";
2424
import type { HomeAssistant } from "../../../../types";
2525
import type {
2626
AreaCardConfig,
27+
ConditionalCardConfig,
2728
DiscoveredDevicesCardConfig,
2829
EmptyStateCardConfig,
2930
HeadingCardConfig,
@@ -35,6 +36,8 @@ import type {
3536
TileCardConfig,
3637
UpdatesCardConfig,
3738
} from "../../cards/types";
39+
import { computeDefaultSecurityAlertVisibility } from "../../cards/security-alerts/helpers";
40+
import { computeSecurityAlertCardEntityConfig } from "../../../security/strategies/security-alerts";
3841
import {
3942
LARGE_SCREEN_CONDITION,
4043
SMALL_SCREEN_CONDITION,
@@ -469,6 +472,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
469472
}
470473
}
471474

475+
const hasVisibleSummaryCards = summaryCards.some(
476+
(card) => !("hide_empty" in card && card.hide_empty)
477+
);
478+
472479
// Build summary cards for sidebar (full width: columns 12)
473480
const sidebarSummaryCards = summaryCards.map((card) => ({
474481
...card,
@@ -515,77 +522,116 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
515522
}
516523
: undefined;
517524

518-
const alertsSection: LovelaceSectionConfig | undefined = config
519-
.alert_entities?.length
520-
? {
521-
type: "grid",
522-
column_span: maxColumns,
523-
cards: [
524-
{
525-
type: "security-alerts",
526-
alert_entities: config.alert_entities,
527-
horizontal: true,
528-
grid_options: { columns: "full" },
529-
} satisfies SecurityAlertsCardConfig,
530-
],
531-
}
532-
: undefined;
525+
const alertEntities = config.alert_entities?.map((alertEntity) =>
526+
computeSecurityAlertCardEntityConfig(
527+
hass.states[alertEntity.entity],
528+
alertEntity
529+
)
530+
);
531+
532+
const alertsSection: LovelaceSectionConfig | undefined =
533+
alertEntities?.length
534+
? {
535+
type: "grid",
536+
column_span: maxColumns,
537+
cards: [
538+
{
539+
type: "security-alerts",
540+
alert_entities: alertEntities,
541+
horizontal: true,
542+
grid_options: { columns: "full" },
543+
} satisfies SecurityAlertsCardConfig,
544+
],
545+
}
546+
: undefined;
547+
548+
const emptyStateCard = {
549+
type: "empty-state",
550+
icon: "mdi:home-assistant",
551+
content_only: true,
552+
title: hass.localize("ui.panel.lovelace.strategy.home.welcome_title"),
553+
content: hass.localize("ui.panel.lovelace.strategy.home.welcome_content"),
554+
...(config.home_panel && hass.user?.is_admin
555+
? {
556+
buttons: [
557+
{
558+
icon: "mdi:plus",
559+
text: hass.localize(
560+
"ui.panel.lovelace.strategy.home.welcome_add_device"
561+
),
562+
appearance: "filled" as const,
563+
variant: "brand" as const,
564+
tap_action: {
565+
action: "fire-dom-event" as const,
566+
home_panel: {
567+
type: "add_integration",
568+
},
569+
},
570+
},
571+
{
572+
icon: "mdi:home-edit",
573+
text: hass.localize(
574+
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
575+
),
576+
appearance: "plain" as const,
577+
variant: "brand" as const,
578+
tap_action: {
579+
action: "navigate" as const,
580+
navigation_path: "/config/areas/dashboard",
581+
},
582+
},
583+
],
584+
}
585+
: {}),
586+
} as EmptyStateCardConfig;
533587

534588
// No sections, show empty state
535589
if (floorsSections.length === 0 && !alertsSection) {
536590
return {
537591
type: "panel",
538-
cards: [
539-
{
540-
type: "empty-state",
541-
icon: "mdi:home-assistant",
542-
content_only: true,
543-
title: hass.localize(
544-
"ui.panel.lovelace.strategy.home.welcome_title"
545-
),
546-
content: hass.localize(
547-
"ui.panel.lovelace.strategy.home.welcome_content"
548-
),
549-
...(config.home_panel && hass.user?.is_admin
550-
? {
551-
buttons: [
552-
{
553-
icon: "mdi:plus",
554-
text: hass.localize(
555-
"ui.panel.lovelace.strategy.home.welcome_add_device"
556-
),
557-
appearance: "filled",
558-
variant: "brand",
559-
tap_action: {
560-
action: "fire-dom-event",
561-
home_panel: {
562-
type: "add_integration",
563-
},
564-
},
565-
},
566-
{
567-
icon: "mdi:home-edit",
568-
text: hass.localize(
569-
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
570-
),
571-
appearance: "plain",
572-
variant: "brand",
573-
tap_action: {
574-
action: "navigate",
575-
navigation_path: "/config/areas/dashboard",
576-
},
577-
},
578-
],
579-
}
580-
: {}),
581-
} as EmptyStateCardConfig,
582-
],
592+
cards: [emptyStateCard],
583593
};
584594
}
585595

596+
const emptyStateSection: LovelaceSectionConfig | undefined =
597+
floorsSections.length === 0 &&
598+
!favoritesSection &&
599+
!hasVisibleSummaryCards &&
600+
alertEntities?.length
601+
? {
602+
type: "grid",
603+
column_span: maxColumns,
604+
cards: [
605+
{
606+
type: "conditional",
607+
conditions: [
608+
{
609+
condition: "not",
610+
conditions: [
611+
{
612+
condition: "or",
613+
conditions: alertEntities.map((alertEntity) => ({
614+
condition: "and",
615+
conditions:
616+
alertEntity.visibility ??
617+
computeDefaultSecurityAlertVisibility(
618+
alertEntity.entity
619+
),
620+
})),
621+
},
622+
],
623+
},
624+
],
625+
card: emptyStateCard,
626+
} satisfies ConditionalCardConfig,
627+
],
628+
}
629+
: undefined;
630+
586631
const sections = (
587632
[
588633
alertsSection,
634+
emptyStateSection,
589635
favoritesSection,
590636
mobileSummarySection,
591637
...floorsSections,
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type { Connection } from "home-assistant-js-websocket";
3+
import type {
4+
HomeFrontendSystemData,
5+
SecurityFrontendSystemData,
6+
} from "../../../src/data/frontend";
7+
import type { HomeAssistant } from "../../../src/types";
8+
import "../../../src/panels/home/ha-panel-home";
9+
import { createMockHass } from "../../fixtures/hass";
10+
11+
vi.hoisted(() => {
12+
Object.assign(globalThis, {
13+
__STATIC_PATH__: "/",
14+
__HASS_URL__: "",
15+
__BUILD__: "modern",
16+
__VERSION__: "test",
17+
__BACKWARDS_COMPAT__: false,
18+
__SUPERVISOR__: false,
19+
__NAMESPACE__: "frontend",
20+
});
21+
});
22+
23+
type TestPanelHome = HTMLElement & {
24+
hass: HomeAssistant;
25+
hassSubscribe(): unknown[];
26+
_config: HomeFrontendSystemData;
27+
_securityConfig: SecurityFrontendSystemData;
28+
} & Record<"_loadConfig", () => Promise<void>>;
29+
30+
const deferred = <T>() => {
31+
let resolve!: (value: T) => void;
32+
const promise = new Promise<T>((promiseResolve) => {
33+
resolve = promiseResolve;
34+
});
35+
return { promise, resolve };
36+
};
37+
38+
describe("ha-panel-home configuration loading", () => {
39+
afterEach(() => {
40+
vi.restoreAllMocks();
41+
});
42+
43+
it("does not overwrite a newer security subscription update", async () => {
44+
const translations = deferred<HomeAssistant["localize"] | undefined>();
45+
let securityChanged:
46+
| ((data: { value: SecurityFrontendSystemData | null }) => void)
47+
| undefined;
48+
const hass = createMockHass();
49+
hass.loadFragmentTranslation = () => translations.promise;
50+
hass.connection = {
51+
sendMessagePromise: vi.fn(async (message: { key: string }) => ({
52+
value:
53+
message.key === "home"
54+
? {}
55+
: { alert_entities: [{ entity: "binary_sensor.old" }] },
56+
})),
57+
subscribeMessage: vi.fn(
58+
(
59+
callback: (data: { value: SecurityFrontendSystemData | null }) => void
60+
) => {
61+
securityChanged = callback;
62+
return Promise.resolve(() => undefined);
63+
}
64+
),
65+
} as unknown as Connection;
66+
67+
const element = document.createElement(
68+
"ha-panel-home"
69+
) as unknown as TestPanelHome;
70+
element.hass = hass;
71+
element.hassSubscribe();
72+
const loading = element._loadConfig();
73+
74+
securityChanged!({
75+
value: { alert_entities: [{ entity: "binary_sensor.new" }] },
76+
});
77+
translations.resolve(undefined);
78+
await loading;
79+
80+
expect(element._securityConfig.alert_entities).toEqual([
81+
{ entity: "binary_sensor.new" },
82+
]);
83+
});
84+
85+
it("preserves loaded Home config when security loading fails", async () => {
86+
vi.spyOn(console, "error").mockImplementation(() => undefined);
87+
const homeConfig: HomeFrontendSystemData = {
88+
favorite_entities: ["light.kitchen"],
89+
hide_suggested_entities: true,
90+
shortcuts: [{ type: "custom", path: "/test", label: "Test" }],
91+
};
92+
const hass = createMockHass();
93+
hass.loadFragmentTranslation = async () => undefined;
94+
hass.connection = {
95+
sendMessagePromise: vi.fn(async (message: { key: string }) => {
96+
if (message.key === "security") {
97+
throw new Error("Security unavailable");
98+
}
99+
return { value: homeConfig };
100+
}),
101+
} as unknown as Connection;
102+
103+
const element = document.createElement(
104+
"ha-panel-home"
105+
) as unknown as TestPanelHome;
106+
element.hass = hass;
107+
element._securityConfig = {
108+
alert_entities: [{ entity: "binary_sensor.old" }],
109+
};
110+
await element._loadConfig();
111+
112+
expect(element._config).toEqual(homeConfig);
113+
expect(element._securityConfig).toEqual({});
114+
});
115+
});

0 commit comments

Comments
 (0)