Compare commits

..

21 Commits

Author SHA1 Message Date
Aidan Timson 43cb94d797 Refresh Home security alerts 2026-08-06 12:03:30 +01:00
Aidan Timson dc629843f0 Show security alerts on Home 2026-08-06 11:48:28 +01:00
Aidan Timson 67b6fac241 Fix security favorites mobile test 2026-08-05 12:05:52 +01:00
Aidan Timson 7e5a2ca351 Add favorites to security dashboard 2026-08-05 11:44:25 +01:00
copilot-swe-agent[bot] 1a843fb400 Address PR review thread feedback 2026-08-05 11:44:24 +01:00
Aidan Timson c3a034cd57 e2e tests 2026-08-05 11:44:24 +01:00
Aidan Timson 77bc71df8c Self review 2026-08-05 11:44:24 +01:00
Aidan Timson e07acd9d06 Remove active alerts heading in preview 2026-08-05 11:44:24 +01:00
Aidan Timson 9e914aebfc Remove heading 2026-08-05 11:44:24 +01:00
Aidan Timson b2b42455cb Cleanup 2026-08-05 11:44:24 +01:00
Aidan Timson 6d7cfb19f5 Cleanup 2026-08-05 11:44:24 +01:00
Aidan Timson 5c59fa530d Swap warning color 2026-08-05 11:44:24 +01:00
Aidan Timson 57b53aa9ce Cleanup 2026-08-05 11:44:24 +01:00
Aidan Timson 0f7e5744cb Fix color 2026-08-05 11:44:24 +01:00
Aidan Timson 5692ab8c5c Preview 2026-08-05 11:44:24 +01:00
Aidan Timson 68b68d751c Fix no color 2026-08-05 11:44:24 +01:00
Aidan Timson 35b29d82bc Fix pulse 2026-08-05 11:44:24 +01:00
Aidan Timson 5826573a66 Type 2026-08-05 11:44:24 +01:00
Aidan Timson dd7910a665 Move into seperate components, use context for heading to hide 2026-08-05 11:44:24 +01:00
Aidan Timson a6b5b9fc25 Settings UI, remove assumptions/mocks 2026-08-05 11:44:24 +01:00
Aidan Timson 991d4da90d Add active security alerts card 2026-08-05 11:44:24 +01:00
70 changed files with 3080 additions and 1291 deletions
-8
View File
@@ -311,15 +311,7 @@ module.exports.config = {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
dashboard: path.resolve(
paths.e2eTestApp_dir,
"src/dashboard-entrypoint.ts"
),
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
onboarding: path.resolve(
paths.e2eTestApp_dir,
"src/onboarding-entrypoint.ts"
),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
+1 -5
View File
@@ -303,11 +303,7 @@ gulp.task(
)
);
const E2E_TEST_APP_PAGE_ENTRIES = {
"index.html": ["main"],
"dashboard.html": ["dashboard"],
"onboarding.html": ["onboarding"],
};
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
+304
View File
@@ -0,0 +1,304 @@
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { mainWindow } from "../../../src/common/dom/get_main_window";
import { navigate } from "../../../src/common/navigate";
import "../../../src/components/ha-button";
import "../../../src/components/ha-card";
import "../../../src/components/ha-icon-button";
import "../../../src/components/ha-svg-icon";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "../stubs/cloud-demo-state";
// Walk the DOM, descending into shadow roots, to find the first matching
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
// can ask it to re-fetch after a scenario change.
const deepQuery = (
selector: string,
root: Document | ShadowRoot = document
): Element | null => {
const direct = root.querySelector(selector);
if (direct) {
return direct;
}
const elements = root.querySelectorAll("*");
for (const element of elements) {
const shadow = element.shadowRoot;
if (shadow) {
const found = deepQuery(selector, shadow);
if (found) {
return found;
}
}
}
return null;
};
/**
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
* reviewers can preview every UI state of the cloud account page. It writes to
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
* then nudges the page to re-read it. Lives entirely under demo/.
*/
@customElement("cloud-demo-controls")
export class CloudDemoControls extends LitElement {
@state() private _open = true;
@state() private _visible = false;
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
private _unsub?: () => void;
// The demo uses hash-based routing (navigate() sets location.hash), so the
// active route lives in the hash, not the pathname.
private get _currentPath(): string {
const hash = mainWindow.location.hash;
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
}
private _locationChanged = () => {
this._visible = this._currentPath.startsWith("/config/cloud");
};
public connectedCallback(): void {
super.connectedCallback();
this._locationChanged();
mainWindow.addEventListener("location-changed", this._locationChanged);
mainWindow.addEventListener("popstate", this._locationChanged);
mainWindow.addEventListener("hashchange", this._locationChanged);
this._unsub = subscribeCloudDemoScenario((scenario) => {
this._scenario = { ...scenario };
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._locationChanged);
mainWindow.removeEventListener("popstate", this._locationChanged);
mainWindow.removeEventListener("hashchange", this._locationChanged);
this._unsub?.();
}
protected render() {
if (!this._visible) {
return nothing;
}
if (!this._open) {
return html`
<ha-icon-button
class="fab"
label="Cloud demo controls"
.path=${mdiFlaskOutline}
@click=${this._toggleOpen}
></ha-icon-button>
`;
}
return html`
<ha-card>
<div class="header">
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
<span class="title">Cloud demo controls</span>
<ha-icon-button
label="Close"
.path=${mdiClose}
@click=${this._toggleOpen}
></ha-icon-button>
</div>
<p class="note">
Demo only. Flips the mocked cloud state shown on this page.
</p>
<div class="controls">
${this._segment("Subscription", "account", [
["active", "Active"],
["trialing", "Trialing"],
["canceled", "Canceled"],
["expired", "Expired"],
["unknown", "Unknown"],
])}
${this._toggle("Onboarded", "onboarded")}
${this._toggle("Onboarding postponed", "postponed")}
${this._toggle("Remote access", "remote")}
${this._segment("Remote status", "remoteStatus", [
["ready", "Ready"],
["generating", "Preparing"],
["loading", "Loading"],
["loaded", "Loaded"],
["error", "Error"],
])}
${this._segment("Backups", "backup", [
["fresh", "Recent"],
["stale", "Old"],
["failed", "Failed"],
["local", "Local only"],
["none", "None"],
])}
${this._toggle("Alexa linked", "alexa")}
${this._toggle("Google linked", "google")}
${this._toggle("Cameras (WebRTC)", "webrtc")}
${this._toggle("Has webhooks", "webhooks")}
</div>
</ha-card>
`;
}
private _segment(
label: string,
field: keyof CloudDemoScenario,
options: [string, string][]
) {
return html`
<div class="row">
<span>${label}</span>
<div class="segment">
${options.map(
([value, text]) => html`
<ha-button
size="s"
appearance=${
this._scenario[field] === value ? "filled" : "plain"
}
data-field=${field}
data-value=${value}
@click=${this._segmentClick}
>
${text}
</ha-button>
`
)}
</div>
</div>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<div class="row">
<span>${label}</span>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._toggleChange}
></ha-switch>
</div>
`;
}
private _toggleOpen() {
this._open = !this._open;
}
private _segmentClick(ev: Event) {
const target = ev.currentTarget as HTMLElement;
this._set(
target.dataset.field as keyof CloudDemoScenario,
target.dataset.value!
);
}
private _toggleChange(ev: Event) {
const target = ev.target as HaSwitch;
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
}
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
this._refresh();
}
private _refresh() {
// Refresh the shared cloud status so login-state changes (signed out) and
// status-derived fields update.
const panel = deepQuery("ha-panel-config");
if (panel) {
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
}
// cloud-account fetches its subscription/backup/webhook data once on mount
// and is not cached by the router, so bounce through a sibling cloud route
// to force a clean remount that re-reads the updated mocks.
const path = this._currentPath;
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
const sibling =
path === "/config/cloud/remote"
? "/config/cloud/account"
: "/config/cloud/remote";
navigate(sibling, { replace: true });
window.setTimeout(() => navigate(path, { replace: true }), 0);
}
}
static styles = css`
:host {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9999;
}
.fab {
--mdc-icon-button-size: 48px;
--mdc-icon-size: 24px;
background-color: var(--primary-color);
color: var(--text-primary-color, #fff);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
ha-card {
display: block;
width: 320px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--divider-color);
}
.header .title {
flex: 1;
font-weight: var(--ha-font-weight-medium, 500);
}
.header ha-svg-icon {
color: var(--secondary-text-color);
}
.note {
margin: 8px 16px;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s, 0.875rem);
}
.controls {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
}
.segment {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"cloud-demo-controls": CloudDemoControls;
}
}
+6 -2
View File
@@ -32,6 +32,7 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -58,8 +59,6 @@ const CONFIG_PANEL_COMMANDS = [
"search/related",
"tag/list",
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
];
@customElement("ha-demo")
@@ -91,6 +90,11 @@ export class HaDemo extends HomeAssistantAppEl {
},
});
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
// the document level; it shows itself only on the cloud panel.
if (!document.querySelector("cloud-demo-controls")) {
document.body.appendChild(document.createElement("cloud-demo-controls"));
}
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+109 -25
View File
@@ -7,43 +7,31 @@ import type {
import { BackupScheduleRecurrence } from "../../../src/data/backup";
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { DemoCloudBackup } from "./cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const CLOUD_AGENT = "cloud.cloud";
// Fixed "recent" backup state: an automatic backup completed 12h ago to both the
// local and cloud agents, with the next run scheduled for tomorrow. This is the
// healthy state the cloud overview status line and backup sub-page render.
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const future = new Date(now + 86400000).toISOString();
const backupInfo: BackupInfo = {
backups: [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: recent,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
],
backups: [],
agent_errors: {},
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
last_action_event: { manager_state: "idle" },
next_automatic_backup: future,
next_automatic_backup: null,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
next_automatic_backup: future,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", CLOUD_AGENT],
@@ -73,6 +61,88 @@ const agentsInfo: BackupAgentsInfo = {
],
};
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
// cloud overview status line and the backup sub-page reflect the chosen state.
const applyScenario = () => {
const kind = getCloudDemoScenario().backup;
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
// actually reads as overdue rather than slipping under the margin.
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
// The cloud agent is a backup target for the cloud-backed states only. For
// "local" a backup exists but is stored locally (no cloud copy), and for
// "none" there are no automatic backups at all.
const cloudEnabled =
kind === "fresh" || kind === "stale" || kind === "failed";
backupConfig.create_backup.agent_ids = cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"];
switch (kind) {
case "fresh":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "local":
// Automatic backups run, but only to the local agent.
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "stale":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
backupConfig.last_attempted_automatic_backup = old;
// Next scheduled backup is in the past, so it reads as overdue.
backupConfig.next_automatic_backup = overdue;
break;
case "failed":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
// Most recent attempt is newer than the last success, so it failed.
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "none":
backupConfig.automatic_backups_configured = false;
backupConfig.last_completed_automatic_backup = null;
backupConfig.last_attempted_automatic_backup = null;
backupConfig.next_automatic_backup = null;
break;
}
backupInfo.last_completed_automatic_backup =
backupConfig.last_completed_automatic_backup;
backupInfo.last_attempted_automatic_backup =
backupConfig.last_attempted_automatic_backup;
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
backupInfo.backups =
cloudEnabled && backupConfig.last_completed_automatic_backup
? [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: backupConfig.last_completed_automatic_backup,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
]
: [];
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
export const mockBackup = (hass: MockHomeAssistant) => {
// Fresh objects each fetch so re-reading after a mutation actually re-renders
// (Lit change detection is identity-based; the real WS API returns new
@@ -101,6 +171,20 @@ export const mockBackup = (hass: MockHomeAssistant) => {
if (update.agents) {
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
}
// Reflect the UI-driven backup change into the demo scenario so the demo
// controls panel stays in sync with the mocked state.
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
const current = getCloudDemoScenario().backup;
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
? "none"
: cloudNow
? current === "fresh" || current === "stale" || current === "failed"
? current
: "fresh"
: "local";
if (next !== current) {
setCloudDemoScenario({ backup: next });
}
return null;
});
hass.mockWS(
+89
View File
@@ -0,0 +1,89 @@
// Demo-only switchable Home Assistant Cloud scenario.
//
// The redesigned cloud account page (src/panels/config/cloud/account) renders
// purely from real WS data. To let reviewers preview every UI state without a
// real cloud account, this module holds a mutable "scenario" that the cloud and
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
// to. It is persisted to localStorage so the choice survives the data the page
// fetches once per visit (subscription, backup config, webhooks).
//
// This lives entirely under demo/ — no production code imports it.
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
// The five PaymentSubscriptionState values.
export type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups are configured, but not to the cloud agent
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
export interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
// Onboarding postponed server-side (maps to onboarding_postponed); hides
// the onboarding UI without marking it completed.
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
account: "active",
onboarded: true,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: true,
webhooks: true,
};
const STORAGE_KEY = "cloudDemoScenario";
const readScenario = (): CloudDemoScenario => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
}
} catch (_err) {
// Ignore malformed or unavailable storage and fall back to the default.
}
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
};
let scenario: CloudDemoScenario = readScenario();
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
export const subscribeCloudDemoScenario = (
listener: (scenario: CloudDemoScenario) => void
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const setCloudDemoScenario = (
partial: Partial<CloudDemoScenario>
): void => {
scenario = { ...scenario, ...partial };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
} catch (_err) {
// Ignore storage failures (e.g. private mode); state still applies in-memory.
}
listeners.forEach((listener) => listener(scenario));
};
+107 -24
View File
@@ -6,6 +6,11 @@ import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
import type { Webhook } from "../../../src/data/webhook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const emptyFilter = () => ({
include_domains: [],
@@ -29,30 +34,14 @@ const demoWebhooks: Webhook[] = [
},
];
const demoCloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
// A single mutable status object seeded to one fixed demo state: an active cloud
// subscription with remote ready, cloud backups recent, webhooks set up and voice
// set up (Alexa linked), but cameras (WebRTC) off, so onboarding is still in
// progress (streaming is the remaining step) and not postponed. Page-driven
// changes (connect remote, postpone onboarding, add/delete webhooks) mutate this
// object directly and reset on reload.
// A single mutable status object so that preference changes made in the demo
// (both via the real UI and the demo scenario controls) are reflected back.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "demo@home-assistant.io",
google_registered: false,
google_registered: true,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: true,
@@ -69,20 +58,20 @@ const cloudStatus: CloudStatusLoggedIn = {
http_use_ssl: false,
active_subscription: true,
onboarding_postponed: false,
onboarding_completed: false,
onboarding_completed: true,
prefs: {
google_enabled: false,
google_enabled: true,
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks: demoCloudhooks,
cloudhooks: {},
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: false,
onboarded_items: [],
cloud_ice_servers_enabled: true,
onboarded_items: [...ONBOARDING_ITEMS],
onboarding_postponed_until: null,
},
};
@@ -104,6 +93,94 @@ const ttsInfo: CloudTTSInfo = {
],
};
// Map the high-level demo scenario onto the mutable cloud status / subscription.
const applyScenario = () => {
const scenario = getCloudDemoScenario();
switch (scenario.account) {
case "trialing":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "trialing" };
break;
case "canceled":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "canceled" };
break;
case "expired":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "expired" };
break;
case "unknown":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "unknown" };
break;
default:
// "active"
cloudStatus.active_subscription = true;
subscription.subscription = { status: "active" };
}
cloudStatus.prefs.onboarded_items = scenario.onboarded
? [...ONBOARDING_ITEMS]
: [];
cloudStatus.onboarding_completed = scenario.onboarded;
cloudStatus.onboarding_postponed = scenario.postponed;
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null;
cloudStatus.prefs.remote_enabled = scenario.remote;
cloudStatus.remote_connected = scenario.remote;
cloudStatus.remote_certificate_status = scenario.remoteStatus;
cloudStatus.alexa_registered = scenario.alexa;
cloudStatus.google_registered = scenario.google;
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
if (scenario.webhooks && !hasCloudhooks) {
cloudStatus.prefs.cloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
} else if (!scenario.webhooks && hasCloudhooks) {
cloudStatus.prefs.cloudhooks = {};
}
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
// back into the demo scenario so the demo controls panel stays in sync with the
// mocked state. Only writes when a value actually changed, to avoid needless
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
// this stays idempotent and does not fight the direct mutation above.
const syncScenarioFromStatus = () => {
const scenario = getCloudDemoScenario();
const next = {
onboarded: cloudStatus.onboarding_completed,
postponed: cloudStatus.onboarding_postponed,
remote: cloudStatus.prefs.remote_enabled,
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
};
if (
scenario.onboarded !== next.onboarded ||
scenario.postponed !== next.postponed ||
scenario.remote !== next.remote ||
scenario.webrtc !== next.webrtc ||
scenario.webhooks !== next.webhooks
) {
setCloudDemoScenario(next);
}
};
export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/status", () => ({
...cloudStatus,
@@ -116,6 +193,7 @@ export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
syncScenarioFromStatus();
return { success: true };
});
@@ -124,6 +202,7 @@ export const mockCloud = (hass: MockHomeAssistant) => {
Date.now() + 24 * 3600 * 1000
).toISOString();
cloudStatus.onboarding_postponed = true;
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
@@ -143,6 +222,7 @@ export const mockCloud = (hass: MockHomeAssistant) => {
(onboardingItem) =>
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
);
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
@@ -165,17 +245,20 @@ export const mockCloud = (hass: MockHomeAssistant) => {
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
delete cloudhooks[msg.webhook_id];
cloudStatus.prefs.cloudhooks = cloudhooks;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/connect", () => {
cloudStatus.remote_connected = true;
cloudStatus.prefs.remote_enabled = true;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
cloudStatus.remote_connected = false;
cloudStatus.prefs.remote_enabled = false;
syncScenarioFromStatus();
return null;
});
-4
View File
@@ -8,14 +8,12 @@ import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
import { mockExpose } from "./expose";
import { mockNetwork } from "./network";
import { mockPerson } from "./person";
import { mockScene } from "./scene";
import { mockSearch } from "./search";
import { mockSlugify } from "./slugify";
import { mockSystemHealth } from "./system_health";
import { mockTags } from "./tags";
import { mockZone } from "./zone";
@@ -41,6 +39,4 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockSearch(hass);
mockTags(hass);
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
};
@@ -1,22 +0,0 @@
import type {
EntityRegistrySettings,
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../src/data/entity/entity_registry_settings";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntityRegistrySettings = (hass: MockHomeAssistant) => {
let settings: EntityRegistrySettings = { entity_id_parts: null };
hass.mockWS<typeof fetchEntityRegistrySettings>(
"config/entity_registry/settings/get",
() => settings
);
hass.mockWS<typeof updateEntityRegistrySettings>(
"config/entity_registry/settings/update",
(msg: Partial<EntityRegistrySettings>) => {
settings = { ...settings, ...msg };
return settings;
}
);
};
-9
View File
@@ -1,9 +0,0 @@
import { slugify } from "../../../src/common/string/slugify";
import type { fetchSlug } from "../../../src/data/ws-slugify";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSlugify = (hass: MockHomeAssistant) => {
hass.mockWS<typeof fetchSlug>("slugify", (msg: { text: string }) => ({
slug: slugify(msg.text),
}));
};
@@ -106,17 +106,6 @@ export class DemoHaSelectBox extends LitElement {
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<label>Disabled with a selected option</label>
<ha-select-box
.value=${"card"}
.options=${fullOptions}
.disabled=${true}
>
</ha-select-box>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Column layout</b></p>
-8
View File
@@ -174,14 +174,6 @@ const CONFIGS = [
entities: ["zone.bushfire"],
},
},
{
heading: "Scale ruler",
config: {
type: "map",
scale_ruler: true,
entities: ["zone.home"],
},
},
] satisfies DemoCardConfig<MapCardConfig>[];
@customElement("demo-lovelace-map-card")
+3 -3
View File
@@ -153,7 +153,7 @@
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.7",
"@rspack/dev-server": "2.2.0",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -186,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.9.0",
"globals": "17.8.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -223,7 +223,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"globals": "17.8.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
+11 -11
View File
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
return undefined;
}
let url: URL;
try {
// anchor.href is always absolute; an empty or unparseable value throws.
url = new URL(anchor.href);
} catch {
let href = anchor.href;
if (!href || href.indexOf("mailto:") !== -1) {
return undefined;
}
// Only intercept same-origin links. A different scheme, host, or port is a
// different origin (e.g. another port like ":8123") and must trigger a full
// browser navigation instead of an in-app route change. Non-http(s) schemes
// such as mailto: resolve to a null origin and are excluded here too.
if (url.origin !== window.location.origin) {
const location = window.location;
const origin = location.origin || location.protocol + "//" + location.host;
if (!href.startsWith(origin)) {
return undefined;
}
href = href.slice(origin.length);
if (href === "#") {
return undefined;
}
if (preventDefault) {
e.preventDefault();
}
return url.pathname + url.search + url.hash;
return href;
};
-13
View File
@@ -1,13 +0,0 @@
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
// only between the first and last character. The hyphen is escaped because a
// `pattern` attribute is compiled with the `v` flag, under which a trailing
// unescaped hyphen is an invalid character class — and a pattern that fails to
// compile is silently ignored, disabling validation altogether.
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
// hostname. Deliberately excludes underscores and a trailing dot.
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
+1 -14
View File
@@ -18,8 +18,6 @@ type HlsLite = Omit<
"subtitleTrackController" | "audioTrackController" | "emeController"
>;
const HIDDEN_CLEANUP_DELAY = 60000;
@customElement("ha-hls-player")
class HaHLSPlayer extends LitElement {
@state()
@@ -78,22 +76,13 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0;
private _hiddenCleanupTimeout?: number;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._hiddenCleanupTimeout = window.setTimeout(() => {
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
}, HIDDEN_CLEANUP_DELAY);
} else if (this._hiddenCleanupTimeout) {
// stream was not cleaned up yet, just cancel the cleanup
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
} else {
this._resetError();
this._startHls();
@@ -116,8 +105,6 @@ class HaHLSPlayer extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
HaHLSPlayer.streamCount -= 1;
this._cleanUp();
}
+1 -2
View File
@@ -56,7 +56,6 @@ export class HaSelectBox extends LitElement {
class="list"
style=${styleMap({ "--columns": columns })}
.value=${this.value}
?disabled=${this.disabled}
@change=${this._radioChanged}
>
${this.options.map((option) => this._renderOption(option))}
@@ -101,7 +100,7 @@ export class HaSelectBox extends LitElement {
)}
aria-labelledby=${`label-${option.value}`}
.value=${option.value}
.disabled=${option.disabled || false}
.disabled=${disabled}
></ha-radio-option>
<div class="text">
<span id=${`label-${option.value}`} class="label"
@@ -53,6 +53,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
value: behavior,
label: this._localizeOption(behavior, "label"),
description: this._localizeOption(behavior, "description"),
disabled: this.disabled,
...(isTrigger && {
image: {
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
@@ -65,7 +66,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value ?? ""}
.disabled=${this.disabled}
max_columns="1"
?stacked_image=${isTrigger}
@value-changed=${this._valueChanged}
@@ -8,6 +8,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { EntitySelector } from "../../data/selector";
@@ -41,6 +42,10 @@ export class HaEntitySelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
entityFilter?: HaEntityPickerEntityFilterFunc;
};
@state() private _createDomains: string[] | undefined;
private _deviceIntegrationLookup = memoizeOne(
@@ -169,6 +174,9 @@ export class HaEntitySelector extends LitElement {
}
private _filterEntities = (entity: HassEntity): boolean => {
if (this.context?.entityFilter && !this.context.entityFilter(entity)) {
return false;
}
if (!this.selector?.entity?.filter) {
return true;
}
@@ -131,7 +131,6 @@ export class HaNumberSelector extends LitElement {
.hint=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.validationMessage=${this.selector.number?.validation_message}
type="number"
autoValidate
.withoutSpinButtons=${!isBox}
@@ -104,7 +104,6 @@ export class HaSelectSelector extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value as string | undefined}
.disabled=${this.disabled}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
></ha-select-box>
+1 -14
View File
@@ -17,8 +17,6 @@ import {
import { apiContext, connectionContext } from "../data/context";
import "./ha-alert";
const HIDDEN_CLEANUP_DELAY = 60000;
/**
* A WebRTC stream is established by first sending an offer through a signal
* path via an integration. An answer is returned, then the rest of the stream
@@ -70,22 +68,13 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = [];
private _hiddenCleanupTimeout?: number;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._hiddenCleanupTimeout = window.setTimeout(() => {
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
}, HIDDEN_CLEANUP_DELAY);
} else if (this._hiddenCleanupTimeout) {
// stream was not cleaned up yet, just cancel the cleanup
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
} else {
this._startWebRtc();
}
@@ -127,8 +116,6 @@ class HaWebRtcPlayer extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
}
-69
View File
@@ -4,7 +4,6 @@ import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type {
Circle,
CircleMarker,
Control,
LatLngExpression,
LatLngTuple,
Layer,
@@ -49,7 +48,6 @@ import type {
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { UNIT_KM } from "../../common/const";
declare global {
// for fire event
@@ -149,9 +147,6 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "cluster-markers", type: Boolean })
public clusterMarkers = true;
@property({ attribute: "scale-ruler", type: Boolean })
public scaleRuler = false;
@state() private _loaded = false;
@query("#map") private _mapElement?: HTMLElement;
@@ -172,8 +167,6 @@ export class HaMap extends ReactiveElement {
private _mapCluster: MarkerClusterGroup | undefined;
private _scaleRulerControl?: Control.Scale;
private _mapPaths: (Polyline | CircleMarker)[] = [];
private _clickCount = 0;
@@ -213,8 +206,6 @@ export class HaMap extends ReactiveElement {
this.Leaflet = undefined;
}
// the control went away with the map, so don't hold on to it
this._scaleRulerControl = undefined;
this._pendingFit = undefined;
this._loaded = false;
@@ -252,16 +243,6 @@ export class HaMap extends ReactiveElement {
this._drawEntities();
}
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
if (
changedProps.has("_loaded") ||
changedProps.has("scaleRuler") ||
(changedProps.has("_config") &&
oldConfig?.unit_system?.length !== this._config?.unit_system?.length)
) {
this._drawScaleRuler();
}
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
}
@@ -825,25 +806,6 @@ export class HaMap extends ReactiveElement {
this._mapZones.forEach((marker) => map.addLayer(marker));
}
private _drawScaleRuler(): void {
if (this._scaleRulerControl) {
this.leafletMap?.removeControl(this._scaleRulerControl);
this._scaleRulerControl = undefined;
}
if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) {
return;
}
const metric = this._config?.unit_system?.length === UNIT_KM;
this._scaleRulerControl = this.Leaflet.control.scale({
position: "bottomleft",
metric,
imperial: !metric,
});
this._scaleRulerControl.addTo(this.leafletMap);
}
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
const markerSizeVarValue =
computedStyles.getPropertyValue("--ha-marker-size");
@@ -924,37 +886,6 @@ export class HaMap extends ReactiveElement {
.leaflet-bottom {
z-index: 1 !important;
}
.leaflet-control-scale {
cursor: unset !important;
}
.leaflet-control-scale-line {
--scale-ruler-color: var(--ha-color-on-surface-default);
--scale-ruler-surface: var(--ha-color-surface-default);
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-body);
color: var(--scale-ruler-color) !important;
background: color-mix(
in srgb,
var(--scale-ruler-surface) 80%,
transparent
) !important;
text-shadow: none !important;
}
/* the theme tokens follow the page, so forced modes need the opposite values */
#map.forced-light .leaflet-control-scale-line {
--scale-ruler-color: var(--ha-color-neutral-05);
--scale-ruler-surface: var(--ha-color-white);
}
#map.forced-dark .leaflet-control-scale-line {
--scale-ruler-color: var(--ha-color-neutral-95);
--scale-ruler-surface: var(--ha-color-neutral-10);
}
.leaflet-left .leaflet-control-scale {
margin-left: 10px !important;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 10px !important;
}
.leaflet-tooltip {
padding: 8px;
font-size: var(--ha-font-size-s);
+14
View File
@@ -1,4 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import type { Condition } from "../panels/lovelace/common/validate-condition";
import type { ShortcutItem } from "./home_shortcuts";
export interface SurveyInteraction {
@@ -35,6 +36,18 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export interface SecurityAlertEntityConfig {
entity: string;
color?: string;
pulse?: boolean;
visibility?: Condition[];
}
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 +64,7 @@ declare global {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
}
}
-73
View File
@@ -1,11 +1,7 @@
import type {
HassEntity,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { stateActive } from "../common/entity/state_active";
import { navigate } from "../common/navigate";
import type { HomeAssistant, ServiceCallResponse } from "../types";
@@ -70,75 +66,6 @@ export type SceneMetaData = Record<
{ entity_only?: boolean | undefined }
>;
// Hand-edited scenes.yaml is parsed as YAML 1.1 by the backend, so unquoted
// on/off arrive here as booleans. The backend applies boolean states as
// on/off; mirror that so the badge reflects what activating the scene will
// actually do. Everything else the backend rejects (numbers, null, arrays,
// objects) yields undefined.
const normalizeSceneEntityState = (sceneState: unknown): string | undefined => {
if (typeof sceneState === "boolean") {
return sceneState ? "on" : "off";
}
if (typeof sceneState === "string") {
return sceneState;
}
return undefined;
};
// Builds a state object from the scene's stored target state, so the scene
// editor can render the icon the entity will have once the scene is applied
// rather than its current live icon. The parameter is typed unknown because
// the scene config API returns raw YAML: booleans, numbers, nulls, and
// malformed shapes occur in hand-edited files beyond what the SceneEntities
// union declares. Entries that hold no usable target state (an entity left
// without a value in the YAML editor parses as null, a dict may lack a state
// key) yield undefined so the caller renders no badge instead of guessing.
//
// The result is a partial HassEntity meant for badge rendering only - it has
// no last_changed, last_updated, or context.
export const sceneEntityStateObj = (
entityId: string,
sceneEntity: unknown
): HassEntity | undefined => {
if (
typeof sceneEntity !== "object" ||
sceneEntity === null ||
Array.isArray(sceneEntity)
) {
const state = normalizeSceneEntityState(sceneEntity);
return state === undefined
? undefined
: ({ entity_id: entityId, state, attributes: {} } as HassEntity);
}
const { state: sceneState, ...attributes } = sceneEntity as Record<
string,
unknown
>;
const state = normalizeSceneEntityState(sceneState);
if (state === undefined) {
return undefined;
}
// Media-derived entity pictures are snapshotted with an access token that
// is stale by the time review mode renders, which would leave the badge
// showing a broken image instead of an icon. Stable pictures on other
// domains are kept - the same policy createHistoricState applies for the
// logbook.
if (DOMAINS_WITH_DYNAMIC_PICTURE.has(computeDomain(entityId))) {
delete attributes.entity_picture;
delete attributes.entity_picture_local;
}
const stateObj = { entity_id: entityId, state, attributes } as HassEntity;
// A live entity never carries color attributes while off, and state-badge
// applies rgb_color and brightness without checking activity; drop them for
// inactive targets so a scene that turns a light off does not render an
// active-looking colored icon.
if (!stateActive(stateObj)) {
delete attributes.rgb_color;
delete attributes.brightness;
}
return stateObj;
};
export const activateScene = (
hass: HomeAssistant,
entityId: string
-3
View File
@@ -397,9 +397,6 @@ export interface NumberSelector {
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
// Shown instead of the browser's native message when the value fails
// min/max/step constraint validation.
validation_message?: string;
} | null;
}
+13 -26
View File
@@ -102,12 +102,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkDataBaseMigration();
}
// Wait for `hass.user` to first populate so the admin guard can run; it
// arrives asynchronously after `hass.config`. `hass.user` also gets a fresh
// reference at runtime (reconnect, profile refresh via subscribeUser), so
// only trigger on the initial population (null -> user). Reconnect re-checks
// come from connection-mixin, the launch-screen swap re-check from update().
if (changedProps.has("hass") && this.hass?.user && !oldHass?.user) {
// Wait for `hass.user` to populate so the admin guard can run; it arrives
// asynchronously after `hass.config`.
if (
changedProps.has("hass") &&
this.hass?.user &&
oldHass?.user !== this.hass.user
) {
this.checkHttpPendingConfig();
}
if (
@@ -124,12 +125,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected update(changedProps: PropertyValues<this>) {
const removingLaunchScreen =
!!this.hass?.states &&
!!this.hass.config &&
!!this.hass.services &&
this._databaseMigration === false;
if (removingLaunchScreen) {
if (
this.hass?.states &&
this.hass.config &&
this.hass.services &&
this._databaseMigration === false
) {
this.render = this.renderHass;
this.update = super.update;
// partial-panel-resolver removes the launch screen after the first panel
@@ -137,13 +138,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
if (removingLaunchScreen) {
// Surface the HTTP pending config dialog only after super.update() has
// committed the render swap above, which clears the launch screen from
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -262,13 +256,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (__DEMO__ || this._httpPendingDialogOpen) {
return;
}
// Only show once the main UI is rendered. During startup the root swaps
// the launch screen for the app, which clears its shadow root and would
// tear the freshly-appended dialog straight back out of the DOM (closing
// it). When called too early we skip; the swap in update() re-runs this.
if (this.render !== this.renderHass) {
return;
}
if (!this.hass?.user?.is_admin) {
return;
}
@@ -49,7 +49,10 @@ export default class HaAutomationActionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled: !this.indent && this.disabled,
disabled:
!this.indent &&
(this.disabled ||
(this.action.enabled === false && !this.yamlMode)),
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -56,7 +56,10 @@ export default class HaAutomationConditionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled: !this.indent && this.disabled,
disabled:
!this.indent &&
(this.disabled ||
(this.condition.enabled === false && !this.yamlMode)),
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -48,7 +48,11 @@ export default class HaAutomationTriggerEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled: this.disabled,
disabled:
this.disabled ||
("enabled" in this.trigger &&
this.trigger.enabled === false &&
!this.yamlMode),
yaml: yamlMode,
card: !this.inSidebar,
})}
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mainWindow } from "../../../common/dom/get_main_window";
import { HOSTNAME_PATTERN } from "../../../common/string/is_hostname";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
@@ -39,16 +38,7 @@ const SCHEMA = memoizeOne(
{
name: "server_port",
required: true,
selector: {
number: {
min: 1,
max: 65535,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_port"
),
},
},
selector: { number: { min: 1, max: 65535, mode: "box" } },
},
{
name: "ssl",
@@ -128,16 +118,7 @@ const SCHEMA = memoizeOne(
{
name: "login_attempts_threshold",
required: true,
selector: {
number: {
min: -1,
max: 1000,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_login_attempts_threshold"
),
},
},
selector: { number: { min: -1, max: 1000, mode: "box" } },
},
],
},
@@ -152,7 +133,7 @@ const SCHEMA = memoizeOne(
selector: {
text: {
multiple: true,
pattern: `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`,
pattern: IP_ADDRESS_PATTERN,
validation_message: localize(
"ui.panel.config.network.http.invalid_host"
),
@@ -462,7 +443,7 @@ class HaConfigHttpForm extends LitElement {
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} target="_blank" rel="noreferrer noopener">${url}</a>
<a href=${url} rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
+14 -48
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistEdit,
mdiTag,
} from "@mdi/js";
import type { HassEntity, HassEvent } from "home-assistant-js-websocket";
import type { HassEvent } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -67,7 +67,6 @@ import {
getSceneEditorInitData,
saveScene,
SCENE_IGNORED_DOMAINS,
sceneEntityStateObj,
showSceneEditor,
} from "../../../data/scene";
import {
@@ -448,15 +447,13 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
const integrationName = platform
? domainToName(this.hass.localize, platform)
: undefined;
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
hasMeta
?twoline=${!!secondary}
graphic="icon"
.graphic=${
this._mode === "live" ? "icon" : undefined
}
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -466,10 +463,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
badgeStateObj
this._mode === "live"
? html`
<state-badge
.stateObj=${badgeStateObj}
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>
`
@@ -555,16 +552,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this.hass.localize,
computeDomain(entityId)
);
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
class="entity"
hasMeta
?twoline=${!!secondary}
graphic="icon"
.graphic=${
this._mode === "live" ? "icon" : undefined
}
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -574,13 +569,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
badgeStateObj
? html`
<state-badge
.stateObj=${badgeStateObj}
slot="graphic"
></state-badge>
`
this._mode === "live"
? html` <state-badge
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>`
: nothing
}
${primary}
@@ -1195,33 +1188,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return { ...stateObj.attributes, state: stateObj.state };
}
// Memoized per config so re-renders reuse the same object references and
// the state badges skip work when nothing changed.
private _sceneStateObjs = memoizeOne((config?: SceneConfig) => {
const objs: Record<string, HassEntity | undefined> = {};
for (const entityId of Object.keys(config?.entities ?? {})) {
objs[entityId] = sceneEntityStateObj(
entityId,
config!.entities[entityId]
);
}
return objs;
});
// Picks the state the row's icon should reflect: the live state in live
// mode, the scene's stored target in review mode. Undefined in review mode
// when the scene holds no usable target for the entity - the row then
// renders no badge rather than a live state that could be mistaken for a
// target.
private _badgeStateObj(
entityId: string,
entityStateObj: HassEntity
): HassEntity | undefined {
return this._mode === "live"
? entityStateObj
: this._sceneStateObjs(this._config)[entityId];
}
private _generateConfigFromLive() {
this._config = {
...this._config!,
@@ -7,6 +7,7 @@ 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 { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "./home-favorite-entity-list-item";
@@ -20,6 +21,11 @@ export class HomeFavoritesEditor extends LitElement {
@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}
@@ -50,10 +56,14 @@ 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 ??
this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)
}
.excludeEntities=${this.favorites}
.entityFilter=${this.entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
+33 -4
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,33 @@ 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>;
public hassSubscribe() {
return [
subscribeFrontendSystemData(
this.hass.connection,
"security",
({ value }) => {
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) {
@@ -151,15 +176,18 @@ class PanelHome extends LitElement {
private async _loadConfig() {
try {
const [_, data] = await Promise.all([
const [_, homeData, securityData] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
this._config = data || {};
this._config = homeData || {};
this._securityConfig = securityData || {};
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load favorites:", err);
console.error("Failed to load configuration:", err);
this._config = {};
this._securityConfig = {};
}
}
@@ -343,6 +371,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,
@@ -218,7 +218,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
.fitZones=${this._config.fit_zones || false}
.themeMode=${themeMode}
.clusterMarkers=${this._clusterMarkers}
.scaleRuler=${this._config.scale_ruler || false}
interactive-zones
render-passive
></ha-map>
@@ -0,0 +1,5 @@
import { createContext } from "@lit/context";
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
export const securityAlertsContext =
createContext<SecurityAlertItem[]>("security-alerts");
@@ -0,0 +1,142 @@
import { ContextProvider, consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeEntityStates } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
configContext,
internationalizationContext,
} from "../../../../data/context";
import {
computeSecurityAlertItem,
computeSecurityAlertItems,
extractSecurityAlertEntityIds,
type SecurityAlertItem,
} from "../../../security/strategies/security-alerts";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { SecurityAlertsCardConfig } from "../types";
import { securityAlertsContext } from "./context";
import "./hui-security-alerts-heading";
import "./hui-security-alerts-list";
@customElement("hui-security-alerts-card")
export class HuiSecurityAlertsCard extends LitElement implements LovelaceCard {
public connectedWhileHidden = true;
@property({ type: Boolean }) public preview = false;
private _alertsProvider = new ContextProvider<{
__context__: SecurityAlertItem[];
}>(this, {
context: securityAlertsContext,
initialValue: [],
});
@state() private _config?: SecurityAlertsCardConfig;
@state() private _alertEntityIds?: string[];
@state()
@consumeEntityStates({ entityIdPath: ["_alertEntityIds"] })
private _states?: Record<string, HassEntity>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
public setConfig(config: SecurityAlertsCardConfig): void {
if (!config.alert_entities) {
throw new Error("Specify alert entities");
}
this._config = config;
this._alertEntityIds = extractSecurityAlertEntityIds(config.alert_entities);
}
public getCardSize(): number {
return this._visibleAlerts.length + 1;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
rows: "auto",
min_columns: 6,
min_rows: 1,
};
}
private get _visibleAlerts(): SecurityAlertItem[] {
const states = this._states;
if (!this._config || !this._alertEntityIds?.length || !states) {
return [];
}
if (this.preview) {
return this._config.alert_entities
.map((alertEntity) => {
const stateObj = states[alertEntity.entity];
return stateObj
? computeSecurityAlertItem(stateObj, alertEntity)
: undefined;
})
.filter((item): item is SecurityAlertItem => Boolean(item));
}
return computeSecurityAlertItems(
{ ...this._hassConfig, ...this._i18n, states },
this._config.alert_entities
);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._config) {
return;
}
const alerts = this._visibleAlerts;
this._alertsProvider.setValue(alerts);
const shouldBeHidden = !this.preview && alerts.length === 0;
if (shouldBeHidden !== this.hidden) {
this.style.display = shouldBeHidden ? "none" : "";
this.toggleAttribute("hidden", shouldBeHidden);
fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden });
this.requestUpdate();
}
}
protected render() {
if (!this._config || this.hidden) {
return nothing;
}
return html`
${
this.preview
? nothing
: html`<hui-security-alerts-heading></hui-security-alerts-heading>`
}
<hui-security-alerts-list
.horizontal=${this._config.horizontal === true}
></hui-security-alerts-list>
`;
}
static styles = css`
:host {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alerts-card": HuiSecurityAlertsCard;
}
}
@@ -0,0 +1,54 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
import { securityAlertsContext } from "./context";
@customElement("hui-security-alerts-heading")
export class HuiSecurityAlertsHeading extends LitElement {
@state()
@consume({ context: securityAlertsContext, subscribe: true })
private _alerts: SecurityAlertItem[] = [];
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
if (!this._alerts.length) {
return nothing;
}
return html`<h2>${this._localize("ui.card.security-alerts.title")}</h2>`;
}
static styles = css`
:host {
display: block;
min-height: 24px;
padding: 0 var(--ha-space-1);
}
h2 {
color: var(--ha-heading-card-title-color, var(--primary-text-color));
font-size: var(--ha-heading-card-title-font-size, var(--ha-font-size-l));
font-weight: var(
--ha-heading-card-title-font-weight,
var(--ha-font-weight-normal)
);
line-height: var(
--ha-heading-card-title-line-height,
var(--ha-line-height-normal)
);
letter-spacing: 0.1px;
margin: 0 0 var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alerts-heading": HuiSecurityAlertsHeading;
}
}
@@ -0,0 +1,171 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../../common/color/compute-color";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-card";
import "../../../../components/ha-relative-time";
import "../../../../components/ha-state-icon";
import "../../../../components/tile/ha-tile-container";
import "../../../../components/tile/ha-tile-icon";
import "../../../../components/tile/ha-tile-info";
import { formattersContext } from "../../../../data/context";
import type { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
import { pulseOpacityAnimation } from "../../../../resources/animations";
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
import { tileCardStyle } from "../tile/tile-card-style";
import { securityAlertsContext } from "./context";
@customElement("hui-security-alerts-list")
export class HuiSecurityAlertsList extends LitElement {
@property({ type: Boolean }) public horizontal = false;
@state()
@consume({ context: securityAlertsContext, subscribe: true })
private _alerts: SecurityAlertItem[] = [];
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
protected render() {
if (!this._alerts.length) {
return nothing;
}
return html`
<div class=${classMap({ alerts: true, horizontal: this.horizontal })}>
${this._alerts.map((alert) => this._renderAlert(alert))}
</div>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
const entityId = (ev.currentTarget as HTMLElement).dataset.entityId;
if (ev.detail.action === "tap" && entityId) {
fireEvent(this, "hass-more-info", { entityId });
}
}
private _renderAlert(alert: SecurityAlertItem) {
const stateDisplay = this._formatters.formatEntityState(alert.stateObj);
const pulse = alert.pulse === true;
const hasColor = alert.color !== undefined && alert.color !== "none";
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-security-alert-color":
alert.color && hasColor ? computeCssColor(alert.color) : undefined,
"--ha-security-alert-static-opacity": pulse
? undefined
: "var(--ha-security-alert-pulse-opacity)",
})}
>
<ha-tile-container
.interactive=${true}
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
data-entity-id=${alert.entityId}
@action=${this._handleAction}
>
<ha-tile-icon
slot="icon"
.icon=${alert.icon}
.iconPath=${alert.iconPath}
>
${
!alert.icon && !alert.iconPath
? html`<ha-state-icon
slot="icon"
.stateObj=${alert.stateObj}
></ha-state-icon>`
: nothing
}
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary">${computeStateName(alert.stateObj)}</span>
<span slot="secondary">
${stateDisplay} ·
<ha-relative-time
.datetime=${alert.stateObj.last_changed}
></ha-relative-time>
</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
pulseOpacityAnimation,
css`
:host {
display: block;
--ha-security-alert-pulse-duration: 1s;
--ha-security-alert-pulse-opacity: 0.3;
--ha-security-alert-static-opacity: 0;
}
.alerts {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.alerts.horizontal {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
ha-card {
position: relative;
overflow: hidden;
height: 100%;
}
ha-card:not(.no-color) {
--tile-color: var(--ha-security-alert-color);
}
ha-card.no-color {
--tile-color: var(--secondary-text-color);
}
ha-card::before {
position: absolute;
inset: 0;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background-color: var(--ha-security-alert-color);
content: "";
opacity: var(--ha-security-alert-static-opacity);
pointer-events: none;
}
ha-card.pulse::before {
--ha-pulse-opacity: var(--ha-security-alert-pulse-opacity);
animation: pulse-opacity var(--ha-security-alert-pulse-duration)
ease-in-out infinite alternate;
}
ha-card:not(.pulse)::before {
animation: none;
}
ha-tile-container {
position: relative;
}
@media (prefers-reduced-motion: reduce) {
ha-card::before {
animation: none;
opacity: var(--ha-security-alert-pulse-opacity);
}
}
@media (max-width: 600px) {
.alerts.horizontal {
grid-template-columns: minmax(0, 1fr);
}
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alerts-list": HuiSecurityAlertsList;
}
}
+6 -1
View File
@@ -3,6 +3,7 @@ import type { EntityNameItem } from "../../../common/entity/compute_entity_name_
import type { HaDurationData } from "../../../components/ha-duration-input";
import type { MapCardMarkerLabelMode } from "../../../components/map/ha-map";
import type { EnergySourceByType } from "../../../data/energy";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type {
@@ -422,7 +423,6 @@ export interface MapCardConfig extends LovelaceCardConfig {
theme_mode?: ThemeMode;
cluster?: boolean;
conditions?: Condition[];
scale_ruler?: boolean;
}
export interface MarkdownCardConfig extends LovelaceCardConfig {
@@ -717,6 +717,11 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface SecurityAlertsCardConfig extends LovelaceCardConfig {
alert_entities: SecurityAlertEntityConfig[];
horizontal?: boolean;
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
title: string;
entities: string[];
@@ -80,6 +80,8 @@ const LAZY_LOAD_TYPES = {
shortcut: () => import("../cards/hui-shortcut-card"),
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
repairs: () => import("../cards/hui-repairs-card"),
"security-alerts": () =>
import("../cards/security-alerts/hui-security-alerts-card"),
updates: () => import("../cards/hui-updates-card"),
gauge: () => import("../cards/hui-gauge-card"),
"history-graph": () => import("../cards/hui-history-graph-card"),
@@ -93,7 +93,6 @@ const cardConfigStruct = assign(
dark_mode: optional(boolean()), // legacy option
theme_mode: optional(string()),
conditions: optional(any()),
scale_ruler: optional(boolean()),
})
);
@@ -164,7 +163,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { mode: "box", min: 0 } },
},
{ name: "scale_ruler", selector: { boolean: {} } },
{ name: "auto_fit", selector: { boolean: {} } },
{ name: "fit_zones", selector: { boolean: {} } },
{ name: "cluster", default: true, selector: { boolean: {} } },
@@ -531,7 +529,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
switch (schema.name) {
case "theme_mode":
case "default_zoom":
case "scale_ruler":
case "auto_fit":
case "fit_zones":
case "cluster":
@@ -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,
@@ -29,6 +30,7 @@ import type {
HomeSummaryCard,
MarkdownCardConfig,
RepairsCardConfig,
SecurityAlertsCardConfig,
ShortcutCardConfig,
TileCardConfig,
UpdatesCardConfig,
@@ -44,6 +46,7 @@ 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;
@@ -512,8 +515,24 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
: undefined;
const alertsSection: LovelaceSectionConfig | undefined = config
.alert_entities?.length
? {
type: "grid",
column_span: maxColumns,
cards: [
{
type: "security-alerts",
alert_entities: config.alert_entities,
horizontal: true,
grid_options: { columns: "full" },
} satisfies SecurityAlertsCardConfig,
],
}
: undefined;
// No sections, show empty state
if (floorsSections.length === 0) {
if (floorsSections.length === 0 && !alertsSection) {
return {
type: "panel",
cards: [
@@ -565,9 +584,12 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
const sections = (
[favoritesSection, mobileSummarySection, ...floorsSections] satisfies (
LovelaceSectionRawConfig | undefined
)[]
[
alertsSection,
favoritesSection,
mobileSummarySection,
...floorsSections,
] satisfies (LovelaceSectionRawConfig | undefined)[]
).filter(Boolean) as LovelaceSectionRawConfig[];
return {
+26 -30
View File
@@ -87,13 +87,11 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
if (!totalWidth) return 1;
const style = getComputedStyle(this);
const wrapper = this.shadowRoot!.querySelector(".wrapper")!;
const wrapperStyle = getComputedStyle(wrapper);
const container = this.shadowRoot!.querySelector(".container")!;
const containerStyle = getComputedStyle(container);
const paddingLeft = parsePx(wrapperStyle.paddingLeft);
const paddingRight = parsePx(wrapperStyle.paddingRight);
const paddingLeft = parsePx(containerStyle.paddingLeft);
const paddingRight = parsePx(containerStyle.paddingRight);
const padding = paddingLeft + paddingRight;
const minColumnWidth = parsePx(
style.getPropertyValue("--column-min-width")
@@ -178,12 +176,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const editMode = this.lovelace.editMode;
const hasSidebar =
this._config?.sidebar && (this._sidebarVisible || editMode);
const singleColumn = this._maxColumns <= 1;
// The tab switcher is opt-in: a sidebar enables it by labelling both tabs,
// otherwise the sidebar stacks below the content.
const useSidebarTabs = Boolean(
singleColumn &&
// The narrow screen tab switcher is opt-in: a sidebar enables it by
// labelling both tabs, otherwise the sidebar stacks below the content.
const useMobileTabs = Boolean(
this.narrow &&
hasSidebar &&
this._config?.sidebar?.content_label &&
this._config?.sidebar?.sidebar_label
@@ -196,10 +192,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
Math.min(this._maxColumns, totalSectionCount),
1
);
const contentColumnCount = hasSidebar
? Math.max(1, columnCount - 1)
: columnCount;
// On mobile with sidebar, content and sidebar both take full width
// (stacked, or switched between via the tabs when opted in)
const contentColumnCount =
hasSidebar && !this.narrow ? Math.max(1, columnCount - 1) : columnCount;
const sectionNeedsMargin = computeSectionsBackgroundAlignment(
sections,
@@ -211,8 +207,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
class="wrapper ${classMap({
"top-margin": Boolean(this._config?.top_margin),
"has-sidebar": Boolean(hasSidebar),
"single-column": singleColumn,
"has-sidebar-tabs": useSidebarTabs,
narrow: this.narrow,
"mobile-tabs-enabled": useMobileTabs,
})}"
style=${styleMap({
"--column-count": columnCount,
@@ -227,9 +223,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.config=${this._config?.header}
></hui-view-header>
${
useSidebarTabs
useMobileTabs
? html`
<div class="sidebar-tabs">
<div class="mobile-tabs">
<ha-control-select
.value=${this._sidebarTabActive ? "sidebar" : "content"}
@value-changed=${this._viewChanged}
@@ -260,7 +256,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<div
class="content ${classMap({
dense: Boolean(this._config?.dense_section_placement),
hidden: useSidebarTabs && this._sidebarTabActive,
"mobile-hidden": useMobileTabs && this._sidebarTabActive,
})}"
>
${repeat(
@@ -347,9 +343,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
? html`
<hui-view-sidebar
class=${classMap({
hidden:
"mobile-hidden":
!hasSidebar ||
(useSidebarTabs && !this._sidebarTabActive),
(useMobileTabs && !this._sidebarTabActive),
})}
.hass=${this.hass}
.badges=${this.badges}
@@ -617,8 +613,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
[sidebar-start] 1fr;
}
/* With a single column, content and sidebar stack at full width */
.wrapper.single-column.has-sidebar .container {
/* On mobile with sidebar, content and sidebar both take full width */
.wrapper.narrow.has-sidebar .container {
grid-template-columns: 1fr;
}
@@ -626,21 +622,21 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
grid-column: sidebar-start / -1;
}
.wrapper.single-column hui-view-sidebar {
.wrapper.narrow hui-view-sidebar {
grid-column: 1 / -1;
}
.wrapper.has-sidebar-tabs hui-view-sidebar {
.wrapper.narrow.mobile-tabs-enabled hui-view-sidebar {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
}
.hidden {
.mobile-hidden {
display: none !important;
}
.sidebar-tabs {
.mobile-tabs {
position: fixed;
bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom));
left: 50%;
@@ -649,7 +645,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
z-index: 1;
}
.sidebar-tabs ha-control-select {
.mobile-tabs ha-control-select {
width: max-content;
min-width: 280px;
max-width: 90%;
@@ -677,11 +673,11 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
gap: var(--row-gap) var(--column-gap);
}
.wrapper.single-column .content {
.wrapper.narrow .content {
grid-column: 1 / -1;
}
.wrapper.has-sidebar-tabs .content {
.wrapper.narrow.mobile-tabs-enabled .content {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
@@ -0,0 +1,216 @@
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } 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 { repeat } from "lit/directives/repeat";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import "../../../components/entity/ha-entity-picker";
import "../../../components/entity/state-badge";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertVisibility } from "../strategies/security-alerts";
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
declare global {
interface HASSDomEvents {
"edit-security-alert-entity": { index: number };
}
}
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public alertEntities: SecurityAlertEntityConfig[] = [];
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.hass.localize(
"ui.panel.security.editor.add_alert_entity"
)}
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
.entityFilter=${this._alertEntityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
}
private _renderAlertEntity(
alertEntity: SecurityAlertEntityConfig,
index: number
) {
const stateObj = this.hass.states[alertEntity.entity];
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
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-icon-button
.path=${mdiPencil}
.label=${this.hass.localize("ui.common.edit")}
data-index=${index}
@click=${this._editClicked}
></ha-icon-button>
<ha-icon-button
.path=${mdiClose}
.label=${this.hass.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 _alertEntityFilter = (entity: HassEntity) =>
isSecurityPanelEntity(this.hass, entity);
private _getIndex(ev: Event): number | undefined {
const index = Number((ev.currentTarget as HTMLElement).dataset.index);
return Number.isInteger(index) ? index : undefined;
}
private _editClicked(ev: Event): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
fireEvent(this, "edit-security-alert-entity", { index });
}
}
private _removeClicked(ev: Event): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
const next = [...this.alertEntities];
next.splice(index, 1);
this._changed(next);
}
}
private _add(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
const entity = ev.detail.value;
if (!entity) return;
(ev.currentTarget as HaEntityPicker).value = "";
if (
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
) {
return;
}
this._changed([
...this.alertEntities,
{
entity,
visibility: computeDefaultSecurityAlertVisibility(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;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"security-alerts-editor": SecurityAlertsEditor;
}
}
@@ -0,0 +1,566 @@
import { ContextProvider } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../components/ha-form/types";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import type {
SecurityAlertEntityConfig,
SecurityFrontendSystemData,
} from "../../../data/frontend";
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 "../../home/components/home-favorites-editor";
import "../../lovelace/cards/hui-card";
import type { SecurityAlertsCardConfig } from "../../lovelace/cards/types";
import "../../lovelace/editor/conditions/ha-card-conditions-editor";
import "../../lovelace/editor/conditions/ha-visibility-status";
import type { Condition } from "../../lovelace/common/validate-condition";
import { conditionsEntityContext } from "../../lovelace/editor/conditions/context";
import "../components/security-alerts-editor";
import {
computeSecurityAlertEntityDefaultColor,
computeDefaultSecurityAlertVisibility,
} from "../strategies/security-alerts";
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
import { withViewTransition } from "../../../common/util/view-transition";
interface AlertEntityEditorData {
entity: string;
color: string;
pulse: boolean;
}
@customElement("dialog-edit-security")
export class DialogEditSecurity
extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(LitElement)
implements HassDialog<EditSecurityDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EditSecurityDialogParams;
@state() private _state?: SecurityFrontendSystemData;
@state() private _open = false;
@state() private _submitting = false;
@state() private _editingAlertEntityIndex?: number;
private _conditionContextProvider = new ContextProvider(this, {
context: conditionsEntityContext,
initialValue: undefined,
});
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
changedProperties.has("_editingAlertEntityIndex") ||
changedProperties.has("_state")
) {
const alertEntity = this._editingAlertEntity;
this._conditionContextProvider.setValue(
alertEntity
? { mode: "current", entityId: alertEntity.entity }
: undefined
);
}
}
private get _editingAlertEntity(): SecurityAlertEntityConfig | undefined {
return this._editingAlertEntityIndex === undefined
? undefined
: this._state?.alert_entities?.[this._editingAlertEntityIndex];
}
public showDialog(params: EditSecurityDialogParams): void {
this._params = params;
this._state = {
...params.config,
favorite_entities: params.config.favorite_entities
? [...params.config.favorite_entities]
: [],
alert_entities: params.config.alert_entities
? [...params.config.alert_entities]
: [],
};
this._initDirtyTracking({ type: "shallow" }, this._state);
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._state = undefined;
this._submitting = false;
this._editingAlertEntityIndex = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._state) {
return nothing;
}
return html`
<ha-dialog
class=${classMap({ subview: Boolean(this._editingAlertEntity) })}
.open=${this._open}
.width=${this._editingAlertEntity ? "large" : "medium"}
.headerTitle=${this.hass.localize("ui.panel.security.editor.title")}
.headerSubtitle=${
this._editingAlertEntity
? undefined
: this.hass.localize("ui.panel.security.editor.description")
}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${
this._editingAlertEntity
? html` ${this._renderAlertEntityEditor(this._editingAlertEntity)} `
: this._renderMainEditor()
}
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderMainEditor() {
return html`
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this.hass.localize(
"ui.panel.security.editor.favorite_entities"
)}
.secondary=${this.hass.localize(
"ui.panel.security.editor.favorite_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
<div class="expansion-content">
<home-favorites-editor
.hass=${this.hass}
.favorites=${this._state?.favorite_entities ?? []}
.entityFilter=${this._alertEntityFilter}
.addButtonLabel=${this.hass.localize(
"ui.panel.security.editor.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></home-favorites-editor>
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this.hass.localize(
"ui.panel.security.editor.active_alert_entities"
)}
.secondary=${this.hass.localize(
"ui.panel.security.editor.active_alert_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:shield-alert"></ha-icon>
<div class="expansion-content">
<security-alerts-editor
.hass=${this.hass}
.alertEntities=${this._state?.alert_entities ?? []}
@value-changed=${this._alertEntitiesChanged}
@edit-security-alert-entity=${this._editAlertEntity}
></security-alerts-editor>
</div>
</ha-expansion-panel>
`;
}
private _renderAlertEntityEditor(alertEntity: SecurityAlertEntityConfig) {
return html`
<div class="entity-editor">
<div class="subpage-header">
<ha-icon-button-prev
.label=${this.hass.localize("ui.common.back")}
@click=${this._closeAlertEntityEditor}
></ha-icon-button-prev>
<span class="subpage-title">
${this.hass.localize("ui.panel.security.editor.edit_alert_entity")}
</span>
</div>
<div class="entity-editor-content">
<div class="element-editor">
<p class="entity-editor-description">
${this.hass.localize(
"ui.panel.security.editor.alert_entity_description"
)}
</p>
<ha-form
.hass=${this.hass}
.data=${{
entity: alertEntity.entity,
color: alertEntity.color,
pulse: alertEntity.pulse ?? true,
}}
.schema=${this._alertEntityFormSchema()}
.context=${{ entityFilter: this._alertEntityFilter }}
.computeLabel=${this._computeAlertEntityEditorLabel}
@value-changed=${this._alertEntityFormChanged}
></ha-form>
<div class="conditions">
<p class="field-label">
${this.hass.localize(
"ui.panel.security.editor.visibility_conditions"
)}
</p>
<ha-visibility-status
.hass=${this.hass}
.conditions=${
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity)
}
></ha-visibility-status>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity)
}
@value-changed=${this._alertEntityConditionsChanged}
></ha-card-conditions-editor>
</div>
</div>
<div class="element-preview">
<hui-card
.hass=${this.hass}
.config=${this._previewCardConfig(alertEntity)}
preview
></hui-card>
</div>
</div>
</div>
`;
}
private _previewCardConfig = memoizeOne(
(alertEntity: SecurityAlertEntityConfig): SecurityAlertsCardConfig => ({
type: "security-alerts",
alert_entities: [alertEntity],
})
);
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 _editAlertEntity(
ev: HASSDomEvent<HASSDomEvents["edit-security-alert-entity"]>
): void {
ev.stopPropagation();
withViewTransition(() => {
this._editingAlertEntityIndex = ev.detail.index;
});
}
private _closeAlertEntityEditor(): void {
this._editingAlertEntityIndex = undefined;
}
private _alertEntityFilter = (entity: HassEntity) =>
isSecurityPanelEntity(this.hass, entity);
private _alertEntityFormSchema(): HaFormSchema[] {
return [
{
name: "entity",
required: true,
selector: {
entity: {
exclude_entities: (this._state?.alert_entities ?? [])
.filter((_, index) => index !== this._editingAlertEntityIndex)
.map(({ entity }) => entity),
},
},
},
{
type: "grid",
name: "highlight",
flatten: true,
column_min_width: "0",
schema: [
{
name: "color",
selector: {
ui_color: {
include_none: true,
default_color: computeSecurityAlertEntityDefaultColor(
this.hass.states[this._editingAlertEntity?.entity ?? ""]
),
},
},
},
{
name: "pulse",
selector: { boolean: {} },
},
],
},
];
}
private _computeAlertEntityEditorLabel = (schema: HaFormSchema): string => {
switch (schema.name) {
case "entity":
return this.hass.localize("ui.panel.security.editor.entity");
case "color":
return this.hass.localize("ui.panel.security.editor.alert_color.label");
case "pulse":
return this.hass.localize("ui.panel.security.editor.pulse");
default:
return schema.name;
}
};
private _updateEditingAlertEntity(
updates: Partial<SecurityAlertEntityConfig>
): void {
if (this._editingAlertEntityIndex === undefined || !this._state) {
return;
}
const alertEntities = [...(this._state.alert_entities ?? [])];
const alertEntity = alertEntities[this._editingAlertEntityIndex];
if (!alertEntity) {
return;
}
alertEntities[this._editingAlertEntityIndex] = {
...alertEntity,
...updates,
};
this._state = {
...this._state,
alert_entities: alertEntities,
};
this._updateDirtyState(this._state);
}
private _alertEntityFormChanged(
ev: ValueChangedEvent<AlertEntityEditorData>
): void {
const updates: Partial<SecurityAlertEntityConfig> = {
entity: ev.detail.value.entity,
color: ev.detail.value.color,
pulse: ev.detail.value.pulse,
};
if (this._editingAlertEntity?.entity !== ev.detail.value.entity) {
updates.visibility = computeDefaultSecurityAlertVisibility(
ev.detail.value.entity
);
}
this._updateEditingAlertEntity(updates);
}
private _alertEntityConditionsChanged(
ev: ValueChangedEvent<Condition[]>
): void {
this._updateEditingAlertEntity({ visibility: ev.detail.value });
}
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-dialog.subview {
--dialog-content-padding: var(--ha-space-2);
}
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);
}
.entity-editor {
display: flex;
flex-direction: column;
}
.subpage-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: 0 var(--ha-space-1);
}
.subpage-title {
color: var(--primary-text-color);
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
.entity-editor-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--ha-space-4) 0;
}
.element-editor {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--ha-space-4);
}
.element-preview {
position: relative;
background: var(--primary-background-color);
padding: var(--ha-space-4);
border-radius: var(--ha-border-radius-sm);
}
.element-preview hui-card {
display: block;
width: 100%;
box-sizing: border-box;
}
@media (min-width: 1000px) {
.entity-editor-content {
flex-direction: row;
max-height: calc(100vh - 209px);
}
.entity-editor-content > .element-editor,
.entity-editor-content > .element-preview {
flex-basis: 0;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.entity-editor-content > .element-preview {
overflow-y: auto;
}
.entity-editor-content > .element-editor {
padding-inline-end: var(--ha-space-4);
}
}
.entity-editor-description {
margin: 0;
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
}
ha-form-grid {
direction: ltr;
--form-grid-column-count: 2;
}
.field-label {
margin: 0 0 var(--ha-space-2) 0;
font-size: 14px;
color: var(--primary-text-color);
}
ha-visibility-status {
display: block;
margin-bottom: 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,
});
};
+81 -3
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,14 @@ 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>;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -53,7 +66,7 @@ class PanelSecurity extends LitElement {
}
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 ||
@@ -77,10 +90,30 @@ class PanelSecurity extends LitElement {
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
this._setLovelace();
}
private async _loadConfig() {
try {
const [, data] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
this._config = data || {};
} catch (err) {
// 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,
});
this._config = {};
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,6 +130,12 @@ class PanelSecurity extends LitElement {
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>
${
this._lovelace
? html`
@@ -118,8 +157,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 +195,35 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
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,224 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiCctvOff, mdiLockOpen, mdiShieldAlert, mdiWater } from "@mdi/js";
import { computeDomain } from "../../../common/entity/compute_domain";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { Condition } from "../../lovelace/common/validate-condition";
import {
checkConditionsMet,
extractConditionEntityIds,
} from "../../lovelace/common/validate-condition";
export interface SecurityAlertItem {
entityId: string;
stateObj: HassEntity;
color?: string;
pulse: boolean;
icon?: string;
iconPath?: string;
}
type SecurityAlertIcon = Pick<SecurityAlertItem, "icon" | "iconPath">;
export type SecurityAlertHass = Pick<
HomeAssistant,
"config" | "locale" | "states" | "user"
>;
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
"carbon_monoxide",
"gas",
"moisture",
"safety",
"smoke",
] as const;
const WARNING_BINARY_SENSOR_DEVICE_CLASSES = [
"door",
"garage_door",
"lock",
"opening",
"tamper",
"window",
] as const;
const WARNING_COVER_DEVICE_CLASSES = [
"door",
"garage",
"gate",
"window",
] as const;
type DangerBinarySensorDeviceClass =
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
type WarningBinarySensorDeviceClass =
(typeof WARNING_BINARY_SENSOR_DEVICE_CLASSES)[number];
type WarningCoverDeviceClass = (typeof WARNING_COVER_DEVICE_CLASSES)[number];
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
const WARNING_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<WarningBinarySensorDeviceClass>(WARNING_BINARY_SENSOR_DEVICE_CLASSES);
const WARNING_COVER_DEVICE_CLASS_SET = new Set<WarningCoverDeviceClass>(
WARNING_COVER_DEVICE_CLASSES
);
const isDangerBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is DangerBinarySensorDeviceClass =>
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as DangerBinarySensorDeviceClass
);
const isWarningBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is WarningBinarySensorDeviceClass =>
WARNING_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as WarningBinarySensorDeviceClass
);
const isWarningCoverDeviceClass = (
deviceClass: string
): deviceClass is WarningCoverDeviceClass =>
WARNING_COVER_DEVICE_CLASS_SET.has(deviceClass as WarningCoverDeviceClass);
export const isSecurityAlertEntity = (stateObj: HassEntity): boolean => {
const domain = computeDomain(stateObj.entity_id);
switch (domain) {
case "alarm_control_panel":
case "camera":
case "lock":
return true;
case "binary_sensor": {
const deviceClass = stateObj.attributes.device_class;
return (
typeof deviceClass === "string" &&
(isDangerBinarySensorDeviceClass(deviceClass) ||
isWarningBinarySensorDeviceClass(deviceClass))
);
}
case "cover": {
const deviceClass = stateObj.attributes.device_class;
return (
typeof deviceClass === "string" &&
isWarningCoverDeviceClass(deviceClass)
);
}
default:
return false;
}
};
export const computeSecurityAlertEntityDefaultColor = (
stateObj?: HassEntity
): string => {
if (!stateObj) {
return "red";
}
const domain = computeDomain(stateObj.entity_id);
if (domain === "camera") {
return "blue";
}
if (domain === "binary_sensor") {
const deviceClass = stateObj.attributes.device_class;
return typeof deviceClass === "string" &&
isWarningBinarySensorDeviceClass(deviceClass)
? "amber"
: "red";
}
if (domain === "cover" || domain === "lock") {
return "amber";
}
return "red";
};
export const computeDefaultSecurityAlertVisibility = (
entityId: string
): Condition[] => [
{
condition: "state",
entity: entityId,
state:
computeDomain(entityId) === "alarm_control_panel" ? "triggered" : "on",
},
];
export const extractSecurityAlertEntityIds = (
alertEntities: SecurityAlertEntityConfig[]
): string[] => [
...new Set(
alertEntities.flatMap((alertEntity) => [
alertEntity.entity,
...extractConditionEntityIds(
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity)
),
])
),
];
const computeSecurityAlertIcon = (stateObj: HassEntity): SecurityAlertIcon => {
const domain = computeDomain(stateObj.entity_id);
if (stateObj.state === UNAVAILABLE && domain === "camera") {
return { iconPath: mdiCctvOff };
}
if (
domain === "binary_sensor" &&
stateObj.attributes.device_class === "moisture"
) {
return { iconPath: mdiWater };
}
if (domain === "lock") {
return { iconPath: mdiLockOpen };
}
if (domain === "alarm_control_panel") {
return { iconPath: mdiShieldAlert };
}
return typeof stateObj.attributes.icon === "string"
? { icon: stateObj.attributes.icon }
: {};
};
export const computeSecurityAlertItem = (
stateObj: HassEntity,
alertEntity: SecurityAlertEntityConfig
): SecurityAlertItem => ({
entityId: stateObj.entity_id,
stateObj,
color: alertEntity.color ?? computeSecurityAlertEntityDefaultColor(stateObj),
pulse: alertEntity.pulse === undefined || alertEntity.pulse === true,
...computeSecurityAlertIcon(stateObj),
});
export const computeSecurityAlertItems = (
hass: SecurityAlertHass,
alertEntities: SecurityAlertEntityConfig[]
): SecurityAlertItem[] =>
alertEntities
.map((alertEntity): SecurityAlertItem | undefined => {
const stateObj = hass.states[alertEntity.entity];
if (!stateObj) {
return undefined;
}
const visibility =
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity);
// checkConditionsMet only reads config, locale, states, and user for
// supported condition types. Keep this helper narrowed to avoid
// reconstructing a full HomeAssistant object from card contexts.
if (
!checkConditionsMet(visibility, hass as HomeAssistant, {
entity_id: alertEntity.entity,
})
) {
return undefined;
}
return computeSecurityAlertItem(stateObj, alertEntity);
})
.filter((item): item is SecurityAlertItem => Boolean(item));
@@ -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,15 @@ 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 { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
}
export const securityEntityFilters: EntityFilter[] = [
@@ -69,6 +73,28 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
const _cachedSecurityEntityFilters = new WeakMap<
HomeAssistant,
ReturnType<typeof generateEntityFilter>[]
>();
const _getSecurityEntityFilters = (hass: HomeAssistant) => {
let filters = _cachedSecurityEntityFilters.get(hass);
if (!filters) {
filters = securityEntityFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
_cachedSecurityEntityFilters.set(hass, filters);
}
return filters;
};
export const isSecurityPanelEntity = (
hass: HomeAssistant,
stateObj: HassEntity
): boolean =>
_getSecurityEntityFilters(hass).some((filter) => filter(stateObj.entity_id));
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
@@ -132,7 +158,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 +175,30 @@ 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) {
const computeTileCard = computeAreaTileCardConfig(hass, "", false);
sections.push({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeTileCard),
],
});
}
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -242,37 +292,51 @@ 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[] = [];
if (config.alert_entities?.length) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "security-alerts",
alert_entities: config.alert_entities,
grid_options: { columns: 12 },
},
] satisfies LovelaceCardConfig[],
});
}
if (hasLogbook && logbookEntityIds.length > 0) {
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,
content_label: hass.localize(
"ui.panel.lovelace.strategy.security.devices"
),
+12
View File
@@ -0,0 +1,12 @@
import { css } from "lit";
export const pulseOpacityAnimation = css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-pulse-opacity, 0.3);
}
}
`;
+30 -7
View File
@@ -256,6 +256,9 @@
"count_updates": "{count} {count, plural,\n one {update available}\n other {updates available}\n}",
"no_updates": "Up to date"
},
"security-alerts": {
"title": "Active alerts"
},
"media_player": {
"source": "Source",
"sound_mode": "Sound mode",
@@ -2609,6 +2612,28 @@
"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 security and safety 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.",
"edit_alert_entity": "Edit alert entity",
"alert_entity_description": "This entity will be displayed and highlighted with the selected color when all the conditions are fulfilled.",
"entity": "Entity",
"add_alert_entity": "Add entity",
"alert_color": {
"label": "Alert color"
},
"pulse": "Pulsate",
"visibility_conditions": "Visibility conditions",
"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.",
@@ -3854,7 +3879,7 @@
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."
"description": "Disable animated view transitions to prevent browser crash when using browser dev tools."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
@@ -3899,7 +3924,7 @@
},
"actions": {
"title": "Actions",
"description": "The actions tool allows you to perform any action available in Home Assistant.",
"description": "The actions dev tool allows you to perform any action available in Home Assistant.",
"call_service": "Perform action",
"response": "Response",
"column_parameter": "Parameter",
@@ -8766,10 +8791,8 @@
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"server_host_warning": "Leave this empty to listen on all interfaces. If you set specific addresses, Home Assistant is only reachable through those — binding it only to an address that other devices can't reach (for example localhost) will make it unreachable from those devices.",
"invalid_host": "Enter a valid IP address or hostname.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"invalid_port": "Enter a port number between 1 and 65535.",
"invalid_login_attempts_threshold": "Enter a number between -1 and 1000.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
@@ -8808,7 +8831,7 @@
},
"helpers": {
"server_port": "The port Home Assistant listens on. Default is {port}.",
"server_host": "IP addresses or hostnames to bind to. Leave empty to listen on all interfaces.",
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
"ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).",
"ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).",
"ssl_peer_certificate": "Absolute path to a client certificate Home Assistant should require for secure connections.",
@@ -9020,6 +9043,7 @@
"security": {
"devices": "Devices",
"other_devices": "Other devices",
"favorites": "[%key:ui::panel::lovelace::strategy::home::favorites%]",
"activity": "Activity"
},
"climate": {
@@ -10201,7 +10225,6 @@
"light": "Light",
"dark": "Dark"
},
"scale_ruler": "Scale ruler",
"default_zoom": "Default zoom",
"auto_fit": "Auto fit",
"fit_zones": "Fit zones",
-136
View File
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { isNavigationClick } from "../../../src/common/dom/is-navigation-click";
const createAnchor = (
href: string,
attributes: Record<string, string> = {}
): HTMLAnchorElement => {
const anchor = document.createElement("a");
anchor.href = href;
for (const [name, value] of Object.entries(attributes)) {
anchor.setAttribute(name, value);
}
return anchor;
};
const clickEvent = (
anchor: HTMLAnchorElement,
overrides: Partial<MouseEvent> = {}
): MouseEvent =>
({
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
composedPath: () => [anchor],
preventDefault: vi.fn(),
...overrides,
}) as unknown as MouseEvent;
describe("isNavigationClick", () => {
it("returns the path for same-origin links", () => {
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}/config`)))
).toEqual("/config");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/energy?historyBack=1`))
)
).toEqual("/energy?historyBack=1");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config/areas#section`))
)
).toEqual("/config/areas#section");
});
it("does not intercept a link on a different port", () => {
// The current origin has no port, so a ported URL shares its string prefix
// but is a different origin — it must not be treated as internal.
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}:8123/`)))
).toBeUndefined();
});
it("does not intercept a host that merely starts with the origin", () => {
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}.example.com/`))
)
).toBeUndefined();
});
it("does not intercept other origins", () => {
expect(
isNavigationClick(clickEvent(createAnchor("https://example.com/")))
).toBeUndefined();
});
it("calls preventDefault for intercepted navigations", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
isNavigationClick(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it("does not preventDefault when disabled", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
expect(isNavigationClick(event, false)).toEqual("/config");
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("ignores clicks with a target, download, or rel=external", () => {
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { target: "_blank" })
)
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config`, { download: "" }))
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { rel: "external" })
)
)
).toBeUndefined();
});
it("ignores modified clicks and non-left buttons", () => {
const anchor = createAnchor(`${location.origin}/config`);
expect(
isNavigationClick(clickEvent(anchor, { button: 1 }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { metaKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { ctrlKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { shiftKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { defaultPrevented: true }))
).toBeUndefined();
});
it("ignores mailto links and clicks without an anchor", () => {
expect(
isNavigationClick(clickEvent(createAnchor("mailto:test@example.com")))
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(undefined as any, {
composedPath: () => [document.createElement("div")],
})
)
).toBeUndefined();
});
});
-82
View File
@@ -1,82 +0,0 @@
import { describe, expect, it } from "vitest";
import { HOSTNAME_PATTERN } from "../../../src/common/string/is_hostname";
import { IP_ADDRESS_PATTERN } from "../../../src/common/string/is_ip_address";
const HOSTNAMES = [
"localhost",
"homeassistant.lan",
"home-assistant.local",
"3com.com",
"sub.domain.example.com",
"a".repeat(63),
];
const INVALID = [
"",
"foo bar",
"foo_bar",
"-foo.lan",
"foo-.lan",
"foo..lan",
"héllo.lan",
"host:8123",
"http://host",
"a".repeat(64),
// All-numeric final label: a mistyped IP address, not a hostname.
"123",
"foo.123",
"300.1.1.1",
"999.999.999.999",
];
// The browser anchors a `pattern` attribute as `^(?:…)$` and compiles it with
// the `v` flag. A pattern that fails to compile under `v` is silently ignored,
// disabling validation entirely, so compile it exactly the same way here.
const patternRegexp = (pattern: string): RegExp =>
new RegExp(`^(?:${pattern})$`, "v");
describe("HOSTNAME_PATTERN", () => {
it("compiles as an HTML pattern attribute", () => {
expect(() => patternRegexp(HOSTNAME_PATTERN)).not.toThrow();
});
it("accepts hostnames", () => {
const regexp = patternRegexp(HOSTNAME_PATTERN);
for (const value of HOSTNAMES) {
expect(regexp.test(value), value).toBe(true);
}
});
it("rejects malformed hostnames", () => {
const regexp = patternRegexp(HOSTNAME_PATTERN);
for (const value of INVALID) {
expect(regexp.test(value), value).toBe(false);
}
});
});
// How ha-config-http-form validates its listen addresses.
describe("IP address or hostname pattern", () => {
const composed = `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`;
it("compiles as an HTML pattern attribute", () => {
expect(() => patternRegexp(composed)).not.toThrow();
});
it("accepts hostnames and IP addresses", () => {
const regexp = patternRegexp(composed);
for (const value of [
...HOSTNAMES,
"192.168.1.10",
"0.0.0.0",
"255.255.255.255",
"fe80::1",
"::1",
]) {
expect(regexp.test(value), value).toBe(true);
}
});
it("rejects malformed addresses", () => {
const regexp = patternRegexp(composed);
for (const value of INVALID) {
expect(regexp.test(value), value).toBe(false);
}
});
});
-116
View File
@@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import { sceneEntityStateObj } from "../../src/data/scene";
describe("sceneEntityStateObj", () => {
it("builds a state object from string shorthand", () => {
expect(sceneEntityStateObj("light.kitchen", "on")).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("builds a state object from the dict form", () => {
expect(
sceneEntityStateObj("light.kitchen", {
state: "on",
brightness: 180,
rgb_color: [255, 64, 112],
})
).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: { brightness: 180, rgb_color: [255, 64, 112] },
});
});
it("strips media-derived entity pictures but keeps other attributes", () => {
const sceneEntity = {
state: "playing",
entity_picture: "/api/media_player_proxy/x?token=stale",
entity_picture_local: "/local/x.jpg",
friendly_name: "Player",
};
expect(sceneEntityStateObj("media_player.x", sceneEntity)).toEqual({
entity_id: "media_player.x",
state: "playing",
attributes: { friendly_name: "Player" },
});
// The input scene config must not be mutated.
expect(sceneEntity.entity_picture).toBe(
"/api/media_player_proxy/x?token=stale"
);
});
it("keeps a stable entity picture on other domains", () => {
expect(
sceneEntityStateObj("vacuum.robot", {
state: "cleaning",
entity_picture: "/local/robot.png",
})?.attributes.entity_picture
).toBe("/local/robot.png");
});
it("returns undefined for null and undefined values", () => {
// An entity left without a value in the YAML editor parses as null.
expect(sceneEntityStateObj("light.kitchen", null)).toBeUndefined();
expect(sceneEntityStateObj("light.kitchen", undefined)).toBeUndefined();
});
it("returns undefined for an array value", () => {
expect(sceneEntityStateObj("light.kitchen", [255, 100])).toBeUndefined();
});
it("returns undefined for a numeric value", () => {
// The backend only accepts string and boolean states, so a number never
// becomes a target the scene can apply.
expect(sceneEntityStateObj("input_number.x", 23)).toBeUndefined();
expect(
sceneEntityStateObj("input_number.x", { state: 23 })
).toBeUndefined();
});
it("returns undefined when the dict holds no usable state", () => {
expect(
sceneEntityStateObj("light.kitchen", { brightness: 100 })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: null })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: { brightness: 100 } })
).toBeUndefined();
});
it("normalizes boolean shorthand to on/off like the backend does", () => {
// YAML 1.1 parses unquoted on/off in scenes.yaml as booleans.
expect(sceneEntityStateObj("light.kitchen", true)?.state).toBe("on");
expect(sceneEntityStateObj("light.kitchen", false)?.state).toBe("off");
});
it("normalizes a boolean state in the dict form", () => {
expect(sceneEntityStateObj("light.kitchen", { state: true })).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("drops color attributes for an inactive target", () => {
// A live entity never carries these while off, and state-badge applies
// them without checking activity.
expect(
sceneEntityStateObj("light.kitchen", {
state: "off",
brightness: 180,
rgb_color: [255, 0, 0],
friendly_name: "Kitchen",
})
).toEqual({
entity_id: "light.kitchen",
state: "off",
attributes: { friendly_name: "Kitchen" },
});
});
});
+160
View File
@@ -279,6 +279,166 @@ test.describe("Lovelace dashboard", () => {
});
});
// ---------------------------------------------------------------------------
// Security panel
// ---------------------------------------------------------------------------
test.describe("Security panel", () => {
test("renders configured security alerts and favorites", async ({ page }) => {
await goToPanel(page, "/?scenario=security-alerts#/security");
await expect(page.locator("ha-panel-security")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
const favoritesSection = page.locator("hui-section", {
has: page.locator("hui-heading-card", { hasText: "Favorites" }),
});
const favoriteCard = favoritesSection.locator("hui-tile-card", {
hasText: "Front door",
});
await expect(favoritesSection).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(favoriteCard).toBeVisible({ timeout: QUICK_TIMEOUT });
const alertCard = page.locator("hui-security-alerts-card").first();
await expect(alertCard).toBeAttached({ timeout: PANEL_TIMEOUT });
if (!(await alertCard.isVisible().catch(() => false))) {
const activityTab = page.getByRole("radio", { name: "Activity" });
if (await activityTab.isVisible().catch(() => false)) {
await activityTab.dispatchEvent("click");
}
}
await expect(alertCard).toBeVisible({ timeout: QUICK_TIMEOUT });
await expect(alertCard.locator("text=Front door")).toBeVisible({
timeout: QUICK_TIMEOUT,
});
await page.evaluate(() => {
(window as any).__mockHass.mockEntities[
"binary_sensor.front_door"
].update({ state: "off" });
});
await expect(alertCard).toBeHidden({ timeout: QUICK_TIMEOUT });
const devicesTab = page.getByRole("radio", { name: "Devices" });
if (await devicesTab.isVisible().catch(() => false)) {
await devicesTab.dispatchEvent("click");
}
await expect(favoriteCard).toBeVisible({ timeout: QUICK_TIMEOUT });
});
});
// ---------------------------------------------------------------------------
// Home panel
// ---------------------------------------------------------------------------
test.describe("Home panel", () => {
test("renders active security alerts before favorites", async ({
page,
}, testInfo) => {
await goToPanel(page, "/?scenario=security-alerts#/home");
await expect(page.locator("ha-panel-home")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
const alertCard = page.locator("hui-security-alerts-card").first();
const alertsSection = page.locator("hui-section", { has: alertCard });
const favoriteTile = page.locator("hui-tile-card", {
hasText: "Front door",
});
const favoritesSection = page.locator("hui-section", {
has: favoriteTile,
});
await expect(alertCard).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(alertCard.locator("text=Front door")).toBeVisible({
timeout: QUICK_TIMEOUT,
});
await expect(favoriteTile).toBeVisible({ timeout: QUICK_TIMEOUT });
const alertsBox = await alertsSection.boundingBox();
const favoritesBox = await favoritesSection.boundingBox();
expect(alertsBox!.y).toBeLessThan(favoritesBox!.y);
await page.evaluate(() => {
(window as any).__mockHass.mockEntities["binary_sensor.window"].update({
state: "on",
});
});
const alertItems = alertCard.locator("hui-security-alerts-list ha-card");
await expect(alertItems).toHaveCount(2, { timeout: QUICK_TIMEOUT });
const firstAlertBox = await alertItems.nth(0).boundingBox();
const secondAlertBox = await alertItems.nth(1).boundingBox();
if (testInfo.project.name === "mobile-chrome") {
expect(secondAlertBox!.y).toBeGreaterThan(firstAlertBox!.y);
} else {
expect(secondAlertBox!.x).toBeGreaterThan(firstAlertBox!.x);
expect(secondAlertBox!.y).toBe(firstAlertBox!.y);
}
await page.evaluate(() => {
(window as any).__mockHass.mockEntities[
"binary_sensor.front_door"
].update({ state: "off" });
(window as any).__mockHass.mockEntities["binary_sensor.window"].update({
state: "off",
});
});
await expect(alertCard).toBeHidden({ timeout: QUICK_TIMEOUT });
await expect(favoriteTile).toBeVisible({ timeout: QUICK_TIMEOUT });
});
test("reloads security alerts after configuration changes", async ({
page,
}) => {
await goToPanel(page, "/?scenario=security-alerts#/home");
const homePanel = page.locator("ha-panel-home");
const alertCard = page.locator("hui-security-alerts-card").first();
await expect(alertCard).toContainText("Front door", {
timeout: PANEL_TIMEOUT,
});
await homePanel.evaluate((element) => {
element.setAttribute("data-cached-panel", "true");
});
await page.evaluate(() => {
window.location.hash = "/security";
});
await expect(page.locator("ha-panel-security")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await page.evaluate(async () => {
window.__mockHass.mockEntities["binary_sensor.window"].update({
state: "on",
});
await window.__mockHass.callWS({
type: "frontend/set_system_data",
key: "security",
value: {
alert_entities: [{ entity: "binary_sensor.window" }],
},
});
window.location.hash = "/home";
});
await expect(
page.locator('ha-panel-home[data-cached-panel="true"]')
).toBeAttached({ timeout: PANEL_TIMEOUT });
await expect(alertCard).toContainText("Window", {
timeout: QUICK_TIMEOUT,
});
await expect(alertCard).not.toContainText("Front door");
});
});
// ---------------------------------------------------------------------------
// More-info dialog (light)
// ---------------------------------------------------------------------------
-3
View File
@@ -1,3 +0,0 @@
{
"cleanUrls": false
}
-2
View File
@@ -1,2 +0,0 @@
import "../../../../src/entrypoints/core";
import "../../../../src/entrypoints/app";
+7
View File
@@ -109,6 +109,13 @@ export const e2eTestPanels: Record<string, E2ETestPanelInfo> = {
url_path: "iframe",
testSelector: "ha-panel-iframe",
},
security: {
component_name: "security",
icon: "mdi:shield-home",
title: "security",
config: null,
url_path: "security",
},
config: {
component_name: "config",
icon: "mdi:cog",
@@ -1,18 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Home Assistant</title>
<%= renderTemplate("../../../../../src/html/_style_base.html.template") %>
</head>
<body>
<div id="ha-launch-screen">
<div id="ha-launch-screen-info-box"></div>
</div>
<home-assistant></home-assistant>
<%= renderTemplate("../../../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../../../src/html/_preload_roboto.html.template") %>
<%= renderTemplate("../../../../../src/html/_script_loader.html.template") %>
</body>
</html>
@@ -1,35 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Home Assistant onboarding</title>
<style>
html,
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
height: 100vh;
margin: 0;
padding: 0;
}
.content {
box-sizing: border-box;
margin: 0 auto;
max-width: 500px;
padding: 32px;
}
</style>
</head>
<body>
<main class="content">
<ha-onboarding></ha-onboarding>
</main>
<%= renderTemplate("../../../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../../../src/html/_preload_roboto.html.template") %>
<%= renderTemplate("../../../../../src/html/_script_loader.html.template") %>
</body>
</html>
@@ -1 +0,0 @@
import "../../../../src/entrypoints/onboarding";
-316
View File
@@ -1,316 +0,0 @@
import { expect, type Page, type WebSocketRoute } from "@playwright/test";
import { demoConfig } from "../../../../src/fake_data/demo_config";
import { demoPanels } from "../../../../src/fake_data/demo_panels";
import { PANEL_TIMEOUT, SHELL_TIMEOUT } from "../../helpers";
interface WebSocketMessage {
id?: number;
type: string;
[key: string]: unknown;
}
export interface OnboardingCalls {
user?: Record<string, unknown>;
coreConfig?: Record<string, unknown>;
coreConfigCompleted?: boolean;
analyticsPreferences?: Record<string, unknown>;
analyticsCompleted?: boolean;
systemData?: Record<string, unknown>;
integration?: Record<string, unknown>;
tokenRequests: string[];
}
export const onboardingData = {
user: {
name: "Test Owner",
username: "test-owner",
password: "test-password",
language: "en",
},
location: {
latitude: 52.3731,
longitude: 4.8903,
elevation: 2,
country: "NL",
currency: "EUR",
timeZone: "Europe/Amsterdam",
unitSystem: "metric",
},
} as const;
const onboardingSteps = [
{ step: "user", done: false },
{ step: "core_config", done: false },
{ step: "analytics", done: false },
{ step: "integration", done: false },
];
const currentUser = {
credentials: [],
id: "onboarding-owner",
is_admin: true,
is_owner: true,
mfa_modules: [],
name: onboardingData.user.name,
};
const subscriptionResults: Record<string, unknown> = {
"config_entries/flow/subscribe": [],
"config_entries/subscribe": [],
"frontend/subscribe_system_data": { value: null },
"frontend/subscribe_user_data": { value: { default_panel: "lovelace" } },
"labs/subscribe": { enabled: false },
"persistent_notification/subscribe": {
type: "current",
notifications: {},
},
subscribe_entities: { a: {} },
};
const commandResults: Record<string, unknown> = {
"analytics/preferences": {},
"auth/current_user": currentUser,
"brands/access_token": { token: "brands-token" },
"config/area_registry/list": [],
"config/core/update": demoConfig,
"config/device_registry/list": [],
"config/entity_registry/list_for_display": {
entities: [],
entity_categories: {},
},
"config/floor_registry/list": [],
"frontend/get_translations": { resources: {} },
"frontend/get_themes": {
default_theme: "default",
default_dark_theme: null,
themes: {},
},
"frontend/set_system_data": undefined,
get_config: demoConfig,
get_panels: { lovelace: demoPanels.lovelace },
get_services: {},
"lovelace/config": {
views: [
{
title: "Home",
cards: [{ type: "heading", heading: "Onboarding complete" }],
},
],
},
"lovelace/info": { resource_mode: "storage" },
"lovelace/resources": [],
"recorder/info": {
migration_in_progress: false,
migration_is_live: false,
},
"repairs/list_issues": { issues: [] },
supported_features: undefined,
};
const sendResult = (socket: WebSocketRoute, id: number, result: unknown) => {
socket.send(
JSON.stringify({
id,
type: "result",
success: true,
result: result ?? null,
})
);
};
const handleWebSocketMessage = (
socket: WebSocketRoute,
rawMessage: string | Buffer,
calls: OnboardingCalls
) => {
const message = JSON.parse(rawMessage.toString()) as WebSocketMessage;
if (message.type === "auth") {
socket.send(JSON.stringify({ type: "auth_ok", ha_version: "2026.8.0" }));
return;
}
if (message.id === undefined) {
throw new Error(`WebSocket command ${message.type} has no id`);
}
if (message.type === "config/core/update") {
calls.coreConfig = message;
} else if (message.type === "analytics/preferences") {
calls.analyticsPreferences = message;
} else if (message.type === "frontend/set_system_data") {
calls.systemData = message;
}
if (message.type === "subscribe_events") {
sendResult(socket, message.id, null);
return;
}
if (message.type === "unsubscribe_events") {
sendResult(socket, message.id, null);
return;
}
if (message.type in subscriptionResults) {
sendResult(socket, message.id, null);
socket.send(
JSON.stringify({
id: message.id,
type: "event",
event: subscriptionResults[message.type],
})
);
return;
}
if (message.type in commandResults) {
sendResult(socket, message.id, commandResults[message.type]);
return;
}
throw new Error(`Unmocked onboarding WebSocket command: ${message.type}`);
};
export async function setupOnboardingMocks(
page: Page
): Promise<OnboardingCalls> {
const calls: OnboardingCalls = { tokenRequests: [] };
await page.route("**/api/onboarding**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === "/api/onboarding") {
await route.fulfill({ json: onboardingSteps });
return;
}
if (pathname === "/api/onboarding/installation_type") {
await route.fulfill({
json: { installation_type: "Home Assistant Container" },
});
return;
}
if (pathname === "/api/onboarding/users") {
calls.user = request.postDataJSON() as Record<string, unknown>;
await route.fulfill({ json: { auth_code: "onboarding-auth-code" } });
return;
}
if (pathname === "/api/onboarding/core_config") {
calls.coreConfigCompleted = true;
await route.fulfill({ json: {} });
return;
}
if (pathname === "/api/onboarding/analytics") {
calls.analyticsCompleted = true;
await route.fulfill({ json: {} });
return;
}
if (pathname === "/api/onboarding/integration") {
calls.integration = request.postDataJSON() as Record<string, unknown>;
await route.fulfill({ json: { auth_code: "dashboard-auth-code" } });
return;
}
await route.abort("failed");
});
await page.route("**/auth/token", (route) => {
calls.tokenRequests.push(route.request().postData() ?? "");
return route.fulfill({
json: {
access_token: "access-token",
expires_in: 1800,
refresh_token: "refresh-token",
token_type: "Bearer",
},
});
});
await page.route("**/auth/revoke", (route) => route.fulfill({ status: 200 }));
await page.routeWebSocket("**/api/websocket", (socket) => {
socket.onMessage((message) =>
handleWebSocketMessage(socket, message, calls)
);
});
return calls;
}
export async function openOnboarding(page: Page, baseURL: string) {
const origin = new URL(baseURL).origin;
const state = btoa(
JSON.stringify({ hassUrl: origin, clientId: `${origin}/` })
);
await page.goto(
`/onboarding.html?client_id=${encodeURIComponent(`${origin}/`)}&redirect_uri=${encodeURIComponent(`${origin}/dashboard.html?auth_callback=1`)}&state=${encodeURIComponent(state)}`
);
await expect(page.locator("onboarding-welcome")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
}
export async function createOwner(page: Page) {
await page
.locator("onboarding-welcome ha-button.start")
.click({ timeout: SHELL_TIMEOUT });
const inputs = page.locator("onboarding-create-user ha-input >> input");
await expect(inputs).toHaveCount(4, { timeout: PANEL_TIMEOUT });
await inputs.nth(0).fill(onboardingData.user.name);
await inputs.nth(1).fill(onboardingData.user.username);
await inputs.nth(2).fill(onboardingData.user.password);
await inputs.nth(3).fill(onboardingData.user.password);
await page
.locator("onboarding-create-user")
.getByRole("button", { name: "Create account", exact: true })
.click();
}
export async function completeCoreConfig(page: Page) {
const location = page.locator("onboarding-location");
await expect(location).toBeAttached({ timeout: PANEL_TIMEOUT });
await location.evaluate((element, locationData) => {
element.dispatchEvent(
new CustomEvent("value-changed", {
bubbles: true,
composed: true,
detail: {
value: {
location: [locationData.latitude, locationData.longitude],
country: locationData.country,
elevation: String(locationData.elevation),
timezone: locationData.timeZone,
currency: locationData.currency,
},
},
})
);
}, onboardingData.location);
}
export async function completeAnalytics(page: Page) {
const analytics = page.locator("onboarding-analytics");
await expect(analytics).toBeAttached({ timeout: PANEL_TIMEOUT });
await analytics.getByRole("button", { name: "Next", exact: true }).click();
}
export async function finishIntegrations(page: Page) {
const integrations = page.locator("onboarding-integrations");
await expect(integrations.locator("ha-button")).toBeVisible({
timeout: PANEL_TIMEOUT,
});
await integrations
.getByRole("button", { name: "Finish", exact: true })
.click();
}
export async function expectDefaultDashboard(page: Page) {
await expect(page).toHaveURL(/\/dashboard\.html#\/lovelace$/, {
timeout: PANEL_TIMEOUT,
});
await expect(page.locator("home-assistant")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
await expect(
page.getByText("Onboarding complete", { exact: true })
).toBeVisible({ timeout: PANEL_TIMEOUT });
}
+75
View File
@@ -3,6 +3,10 @@ import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
} from "../../../../../src/data/entity/entity_registry";
import type {
HomeFrontendSystemData,
SecurityFrontendSystemData,
} from "../../../../../src/data/frontend";
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
import type { MediaPlayerItem } from "../../../../../src/data/media-player";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
@@ -240,6 +244,76 @@ const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
hass.mockWS("media_source/browse_media", () => browsePromise);
};
const securityAlertsScenario: Scenario = async (hass) => {
const homeData: HomeFrontendSystemData = {
favorite_entities: ["binary_sensor.front_door"],
hide_suggested_entities: true,
};
let securityData: SecurityFrontendSystemData = {
alert_entities: [
{ entity: "binary_sensor.front_door" },
{ entity: "binary_sensor.window" },
],
favorite_entities: ["binary_sensor.front_door"],
};
hass.addEntities([
{
entity_id: "binary_sensor.front_door",
state: "on",
attributes: {
friendly_name: "Front door",
device_class: "door",
},
},
{
entity_id: "binary_sensor.window",
state: "off",
attributes: {
friendly_name: "Window",
device_class: "window",
},
},
]);
hass.mockWS("frontend/get_system_data", (msg: { key: string }) => {
const value =
msg.key === "security"
? securityData
: msg.key === "home"
? homeData
: null;
return { value };
});
let securityDataChanged:
((data: { value: SecurityFrontendSystemData }) => void) | undefined;
hass.mockWS(
"frontend/subscribe_system_data",
(msg: { key: string }, _currentHass, onChange) => {
if (msg.key !== "security") {
onChange?.({ value: null });
return () => undefined;
}
securityDataChanged = onChange;
onChange?.({ value: securityData });
return () => {
if (securityDataChanged === onChange) {
securityDataChanged = undefined;
}
};
}
);
hass.mockWS(
"frontend/set_system_data",
(msg: { key: string; value: SecurityFrontendSystemData }) => {
if (msg.key === "security") {
securityData = msg.value;
securityDataChanged?.({ value: securityData });
}
}
);
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -255,4 +329,5 @@ export const scenarios: Record<string, Scenario> = {
"light-more-info": lightMoreInfoScenario,
"quick-search-assist": quickSearchAssistScenario,
"delayed-lovelace": delayedLovelaceScenario,
"security-alerts": securityAlertsScenario,
};
-63
View File
@@ -1,63 +0,0 @@
import { expect, test } from "@playwright/test";
import { demoConfig } from "../../src/fake_data/demo_config";
import {
completeAnalytics,
completeCoreConfig,
createOwner,
expectDefaultDashboard,
finishIntegrations,
onboardingData,
openOnboarding,
setupOnboardingMocks,
} from "./app/src/onboarding";
import { expectNoPageErrors, trackPageErrors } from "./helpers";
test.use({ serviceWorkers: "block" });
test("completes onboarding and opens the default dashboard", async ({
page,
baseURL,
}) => {
const errors = trackPageErrors(page);
const calls = await setupOnboardingMocks(page);
await test.step("welcome", () => openOnboarding(page, baseURL!));
await test.step("create account", () => createOwner(page));
await test.step("configure Home Assistant", () => completeCoreConfig(page));
await test.step("choose analytics", () => completeAnalytics(page));
await test.step("finish integrations", () => finishIntegrations(page));
await test.step("open default dashboard", () => expectDefaultDashboard(page));
expect(calls.user).toMatchObject(onboardingData.user);
expect(calls.coreConfig).toMatchObject({
type: "config/core/update",
latitude: onboardingData.location.latitude,
longitude: onboardingData.location.longitude,
elevation: onboardingData.location.elevation,
unit_system: onboardingData.location.unitSystem,
time_zone: onboardingData.location.timeZone,
currency: onboardingData.location.currency,
country: onboardingData.location.country,
});
expect(calls.coreConfigCompleted).toBe(true);
expect(calls.analyticsPreferences).toMatchObject({
type: "analytics/preferences",
preferences: {},
});
expect(calls.analyticsCompleted).toBe(true);
expect(calls.systemData).toMatchObject({
type: "frontend/set_system_data",
key: "core",
value: {
onboarded_version: demoConfig.version,
onboarded_date: expect.any(String),
},
});
expect(calls.integration).toMatchObject({
client_id: expect.any(String),
redirect_uri: expect.stringContaining("/dashboard.html?auth_callback=1"),
});
expect(calls.tokenRequests).toHaveLength(2);
expect(calls.tokenRequests[1]).toContain("dashboard-auth-code");
expectNoPageErrors(errors);
});
+3 -3
View File
@@ -6,7 +6,7 @@ const APP_BASE_URL = `http://localhost:${APP_PORT}`;
export default defineConfig({
testDir: ".",
testMatch: ["app.spec.ts", "onboarding.spec.ts"],
testMatch: "app.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
@@ -38,8 +38,8 @@ export default defineConfig({
webServer: {
command: process.env.CI
? `npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s -c ../serve.json`
: `./node_modules/.bin/gulp build-e2e-test-app && npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s -c ../serve.json`,
? `npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`
: `./node_modules/.bin/gulp build-e2e-test-app && npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`,
url: APP_BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
@@ -0,0 +1,102 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { afterEach, describe, expect, it } from "vitest";
import type { SecurityAlertItem } from "../../../../../src/panels/security/strategies/security-alerts";
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-list";
interface TestSecurityAlertsList extends HTMLElement {
horizontal: boolean;
updateComplete: Promise<boolean>;
_alerts: SecurityAlertItem[];
_formatters: {
formatEntityState: (stateObj: HassEntity) => string;
};
}
const state = (): HassEntity => ({
entity_id: "binary_sensor.window",
state: "on",
attributes: {
device_class: "window",
friendly_name: "Window",
},
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
context: { id: "", parent_id: null, user_id: null },
});
const alert = (pulse: boolean, color?: string): SecurityAlertItem => {
const stateObj = state();
return {
entityId: stateObj.entity_id,
stateObj,
pulse,
color,
};
};
const createList = async (alerts: SecurityAlertItem[]) => {
const element = document.createElement(
"hui-security-alerts-list"
) as unknown as TestSecurityAlertsList;
element._alerts = alerts;
element._formatters = { formatEntityState: () => "On" };
document.body.appendChild(element);
await element.updateComplete;
return element;
};
describe("hui-security-alerts-list", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("does not pulse when pulse is disabled", async () => {
const element = await createList([alert(false)]);
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("pulse")).toBe(false);
expect(
card.style.getPropertyValue("--ha-security-alert-static-opacity")
).toBe("var(--ha-security-alert-pulse-opacity)");
});
it("pulses when pulse is enabled", async () => {
const element = await createList([alert(true)]);
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("pulse")).toBe(true);
});
it("applies configured colors", async () => {
const element = await createList([alert(true, "amber")]);
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("warning")).toBe(false);
expect(card.classList.contains("no-color")).toBe(false);
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe(
"var(--amber-color)"
);
});
it("does not apply a color when color is none", async () => {
const element = await createList([alert(true, "none")]);
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("warning")).toBe(false);
expect(card.classList.contains("no-color")).toBe(true);
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe("");
});
it("lays out alerts horizontally when configured", async () => {
const element = await createList([alert(false), alert(false)]);
element.horizontal = true;
await element.updateComplete;
expect(element.shadowRoot!.querySelector(".alerts")!.classList).toContain(
"horizontal"
);
});
});
@@ -0,0 +1,302 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import {
computeDefaultSecurityAlertVisibility,
computeSecurityAlertEntityDefaultColor,
computeSecurityAlertItems,
isSecurityAlertEntity,
type SecurityAlertHass,
} from "../../../../../src/panels/security/strategies/security-alerts";
const state = (
entityId: string,
value: string,
deviceClass: string | undefined,
lastChanged: string
): HassEntity => ({
entity_id: entityId,
state: value,
attributes: {
...(deviceClass ? { device_class: deviceClass } : {}),
friendly_name: entityId,
},
last_changed: lastChanged,
last_updated: lastChanged,
context: { id: "", parent_id: null, user_id: null },
});
const hass = (states: Record<string, HassEntity>): SecurityAlertHass => ({
states,
user: undefined,
config: {
time_zone: "UTC",
} as SecurityAlertHass["config"],
locale: {
time_zone: "server",
} as SecurityAlertHass["locale"],
});
describe("computeDefaultSecurityAlertVisibility", () => {
it("defaults alarm panels to triggered", () => {
expect(
computeDefaultSecurityAlertVisibility("alarm_control_panel.house")
).toEqual([
{
condition: "state",
entity: "alarm_control_panel.house",
state: "triggered",
},
]);
});
it("defaults other entities to on", () => {
expect(computeDefaultSecurityAlertVisibility("binary_sensor.leak")).toEqual(
[
{
condition: "state",
entity: "binary_sensor.leak",
state: "on",
},
]
);
});
});
describe("computeSecurityAlertEntityDefaultColor", () => {
it("uses device class defaults independent of current state", () => {
expect(
computeSecurityAlertEntityDefaultColor(
state(
"binary_sensor.carbon_monoxide",
"unavailable",
"carbon_monoxide",
"2026-01-01T00:00:00Z"
)
)
).toBe("red");
expect(
computeSecurityAlertEntityDefaultColor(
state(
"binary_sensor.window",
"unavailable",
"window",
"2026-01-01T00:00:00Z"
)
)
).toBe("amber");
expect(
computeSecurityAlertEntityDefaultColor(
state("camera.patio", "unavailable", undefined, "2026-01-01T00:00:00Z")
)
).toBe("blue");
});
});
describe("isSecurityAlertEntity", () => {
it("includes entities that can be active alerts", () => {
expect(
isSecurityAlertEntity(
state(
"binary_sensor.dishwasher_leak",
"off",
"moisture",
"2026-01-01T00:00:00Z"
)
)
).toBe(true);
expect(
isSecurityAlertEntity(
state("lock.front_door", "locked", undefined, "2026-01-01T00:00:00Z")
)
).toBe(true);
expect(
isSecurityAlertEntity(
state("camera.patio", "idle", undefined, "2026-01-01T00:00:00Z")
)
).toBe(true);
});
it("excludes entities that do not classify as alerts", () => {
expect(
isSecurityAlertEntity(
state("binary_sensor.motion", "off", "motion", "2026-01-01T00:00:00Z")
)
).toBe(false);
expect(
isSecurityAlertEntity(
state("light.kitchen", "on", undefined, "2026-01-01T00:00:00Z")
)
).toBe(false);
});
});
describe("computeSecurityAlertItems", () => {
it("does not infer alerts without configured rows", () => {
const states = {
"binary_sensor.dishwasher_leak": state(
"binary_sensor.dishwasher_leak",
"on",
"moisture",
"2026-01-01T00:00:00Z"
),
};
expect(computeSecurityAlertItems(hass(states), [])).toEqual([]);
});
it("shows configured entities when their default visibility matches", () => {
const states = {
"binary_sensor.dishwasher_leak": state(
"binary_sensor.dishwasher_leak",
"on",
"moisture",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.dishwasher_leak" },
]).map((item) => item.entityId)
).toEqual(["binary_sensor.dishwasher_leak"]);
});
it("shows carbon monoxide sensors when active", () => {
const states = {
"binary_sensor.carbon_monoxide": state(
"binary_sensor.carbon_monoxide",
"on",
"carbon_monoxide",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.carbon_monoxide" },
]).map((item) => item.entityId)
).toEqual(["binary_sensor.carbon_monoxide"]);
});
it("hides configured entities when their default visibility does not match", () => {
const states = {
"binary_sensor.dishwasher_leak": state(
"binary_sensor.dishwasher_leak",
"off",
"moisture",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.dishwasher_leak" },
])
).toEqual([]);
});
it("uses custom visibility conditions", () => {
const states = {
"lock.front_door": state(
"lock.front_door",
"unlocked",
undefined,
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{
entity: "lock.front_door",
visibility: [
{
condition: "state",
entity: "lock.front_door",
state: "unlocked",
},
],
},
]).map((item) => item.entityId)
).toEqual(["lock.front_door"]);
});
it("applies configured color and pulse", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{
entity: "binary_sensor.window",
color: "red",
pulse: false,
},
])[0]
).toMatchObject({ color: "red", pulse: false });
});
it("uses the entity default color when color is not configured", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.window" },
])[0]
).toMatchObject({ color: "amber" });
});
it("keeps no color as an explicit color choice", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.window", color: "none" },
])[0]
).toMatchObject({ color: "none" });
});
it("keeps configured order", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:02:00Z"
),
"binary_sensor.leak": state(
"binary_sensor.leak",
"on",
"moisture",
"2026-01-01T00:01:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.window" },
{ entity: "binary_sensor.leak" },
]).map((item) => item.entityId)
).toEqual(["binary_sensor.window", "binary_sensor.leak"]);
});
});
+10 -10
View File
@@ -4918,9 +4918,9 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-server@npm:2.2.0":
version: 2.2.0
resolution: "@rspack/dev-server@npm:2.2.0"
"@rspack/dev-server@npm:2.1.0":
version: 2.1.0
resolution: "@rspack/dev-server@npm:2.1.0"
dependencies:
"@rspack/dev-middleware": "npm:^2.0.3"
peerDependencies:
@@ -4929,7 +4929,7 @@ __metadata:
peerDependenciesMeta:
selfsigned:
optional: true
checksum: 10/edfc57223fad64c43c96be95b0b6650194ac2e02d929bef649d524faa9c618233afe8f970ac2e21c77e1b92f6c65dac20f06817e73a9bd661f43c73057ae6b6b
checksum: 10/402d4f96c60beaba354081a36b053c3cf7c1dda7fddab791031dc4206b9873b98437895d5a6a68247e07cb8a5922a1770143c560772dfdaa00ba90a5396251d8
languageName: node
linkType: hard
@@ -9669,10 +9669,10 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:17.9.0":
version: 17.9.0
resolution: "globals@npm:17.9.0"
checksum: 10/8619386d449e8cbb81e242eced8bca0a42e5c3633a3a35996b8b1618beb17ff9fb33bfe703d60d83d4b7f6f8a996dede4faf02f9e966e50ff895e0cf6122ae15
"globals@npm:17.8.0":
version: 17.8.0
resolution: "globals@npm:17.8.0"
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
languageName: node
linkType: hard
@@ -9928,7 +9928,7 @@ __metadata:
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.7"
"@rspack/dev-server": "npm:2.2.0"
"@rspack/dev-server": "npm:2.1.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.3.2"
@@ -9983,7 +9983,7 @@ __metadata:
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.9.0"
globals: "npm:17.8.0"
gulp: "npm:5.0.1"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"