Compare commits

...
Author SHA1 Message Date
Bram Kragten 235541748b Bumped version to 20260729.6 2026-08-07 16:30:37 +02:00
Paul BotteinandBram Kragten 6bd1d698ce Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 16:27:35 +02:00
Bram Kragten 18862b868f Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-07 16:27:33 +02:00
Bram Kragten 662d817a5e Fix link to different port being handled as internal link (#53530) 2026-08-07 16:27:32 +02:00
Aidan TimsonandBram Kragten 7412ab5578 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-07 16:27:31 +02:00
Krisjanis LejejsandBram Kragten b2be0bd673 Remove cloud demo controls (#53524) 2026-08-07 16:27:30 +02:00
Bram Kragten a141432546 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-07 16:27:29 +02:00
Paul BotteinandBram Kragten cd50531b67 Stack the sidebar when only one column fits (#53515) 2026-08-07 16:27:29 +02:00
Petar PetrovandBram Kragten f7438f4d0e Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-07 16:27:28 +02:00
Petar PetrovandBram Kragten a7fabb8a8c Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-07 16:26:46 +02:00
Aidan TimsonandBram Kragten 9dab55f39a Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
(cherry picked from commit d03ba15c09)

# Conflicts:
#	test/panels/config/script/script-paste.test.ts
2026-08-07 16:23:38 +02:00
Bram Kragten 7ac98b7fdd Bumped version to 20260729.5 2026-08-05 09:56:45 +02:00
karwostsandBram Kragten 5c1c92e8ca Fix missing label in solar forecast dialog (#53506) 2026-08-05 09:56:31 +02:00
Paul BotteinandBram Kragten bf9765d557 Fix back navigation loop on the cloud page (#53497) 2026-08-05 09:56:30 +02:00
Petar PetrovandBram Kragten b013b1f929 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 09:53:55 +02:00
Petar PetrovandBram Kragten 8026a59e70 Fix wrong month in date picker calendar header (#53453) 2026-08-05 09:50:37 +02:00
Bram Kragten ffa7cf17a6 Bumped version to 20260729.4 2026-08-04 13:19:17 +02:00
karwostsandBram Kragten 5a5cec9060 Fix slow loading of entity rows in device page (#53471)
Fix lazy loading of entity rows in device page
2026-08-04 12:50:36 +02:00
Petar PetrovandBram Kragten bb962ba3d8 Fix energy default period being saved under the wrong storage key (#53462) 2026-08-04 12:50:35 +02:00
d4a59ff1c7 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`getCollection()` caches collections (entity registry, label registry, ...) on
the `Connection` object. A custom panel embedded in an iframe shares the
connection with the main window, so a collection the panel is the first to
request is created inside the iframe's realm.

When the user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-04 12:50:34 +02:00
karwostsandBram Kragten f496d842a6 Allow to favorite the manual card (#53445) 2026-08-04 12:50:33 +02:00
Petar PetrovandBram Kragten c14d07c845 Stop the statistics graph card editor looping while typing a name (#53434) 2026-08-04 12:50:32 +02:00
Petar PetrovandBram Kragten 8a1f2e86bd Show progress on the Zigbee backup button while the backup is created (#53422) 2026-08-04 12:49:55 +02:00
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul BotteinandBram Kragten 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan TimsonandBram Kragten d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G.andBram Kragten 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwostsandBram Kragten 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram KragtenandSimon Lamon ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <[email protected]>

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <[email protected]>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar PetrovandBram Kragten 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar PetrovandBram Kragten ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-07-30 23:25:15 +02:00
karwostsandBram Kragten 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar PetrovandBram Kragten 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
7edb9f8164 Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 23:25:12 +02:00
Bram Kragten ff814167c6 Bumped version to 20260729.1 2026-07-30 10:41:32 +02:00
Paul BotteinandBram Kragten d08d8dde64 Show integration logo and name in the replace device dialog (#53384) 2026-07-30 10:41:12 +02:00
Bram Kragten 5da441ceed Strip empty strings from http config (#53377) 2026-07-30 10:41:11 +02:00
Paul BotteinandBram Kragten 22e646a68a Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:41:11 +02:00
d0ab20479f Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 10:41:10 +02:00
112 changed files with 3005 additions and 1461 deletions
-304
View File
@@ -1,304 +0,0 @@
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
View File
@@ -32,7 +32,6 @@ 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.
@@ -90,11 +89,6 @@ 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(
+4 -10
View File
@@ -34,16 +34,8 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -64,12 +56,14 @@
padding: 0;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
@@ -104,7 +98,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+25 -109
View File
@@ -7,31 +7,43 @@ 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: [],
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,
],
agent_errors: {},
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
next_automatic_backup: null,
next_automatic_backup: future,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
next_automatic_backup: future,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", CLOUD_AGENT],
@@ -61,88 +73,6 @@ 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
@@ -171,20 +101,6 @@ 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
@@ -1,89 +0,0 @@
// 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));
};
+24 -107
View File
@@ -6,11 +6,6 @@ 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: [],
@@ -34,14 +29,30 @@ const demoWebhooks: Webhook[] = [
},
];
// 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 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.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: true,
google_registered: false,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: true,
@@ -58,20 +69,20 @@ const cloudStatus: CloudStatusLoggedIn = {
http_use_ssl: false,
active_subscription: true,
onboarding_postponed: false,
onboarding_completed: true,
onboarding_completed: false,
prefs: {
google_enabled: true,
google_enabled: false,
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks: {},
cloudhooks: demoCloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: true,
onboarded_items: [...ONBOARDING_ITEMS],
cloud_ice_servers_enabled: false,
onboarded_items: [],
onboarding_postponed_until: null,
},
};
@@ -93,94 +104,6 @@ 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,
@@ -193,7 +116,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
syncScenarioFromStatus();
return { success: true };
});
@@ -202,7 +124,6 @@ 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 } };
});
@@ -222,7 +143,6 @@ 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 } };
});
@@ -245,20 +165,17 @@ 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;
});
@@ -23,7 +23,7 @@ export class DemoHaProgressButton extends LitElement {
<ha-progress-button @click=${this._clickedFail}>
Fail
</ha-progress-button>
<ha-progress-button size="small" @click=${this._clickedSuccess}>
<ha-progress-button size="s" @click=${this._clickedSuccess}>
small
</ha-progress-button>
<ha-progress-button
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260729.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+23
View File
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
+11 -11
View File
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
return undefined;
}
let href = anchor.href;
if (!href || href.indexOf("mailto:") !== -1) {
let url: URL;
try {
// anchor.href is always absolute; an empty or unparseable value throws.
url = new URL(anchor.href);
} catch {
return undefined;
}
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 === "#") {
// 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) {
return undefined;
}
if (preventDefault) {
e.preventDefault();
}
return href;
return url.pathname + url.search + url.hash;
};
@@ -1,15 +0,0 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+13
View File
@@ -0,0 +1,13 @@
// 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}`;
+52
View File
@@ -0,0 +1,52 @@
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
const HOME_ASSISTANT_SCHEME = "homeassistant://";
/**
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
* Only absolute `http:` and `https:` URLs pass, the same check
* `ha-attribute-value` already applies to attribute links.
*
* Use for every URL that reaches the frontend as data — from an integration
* manifest, an add-on, a config flow or an entity attribute — so that a
* `javascript:` URI can never become a clickable link.
*/
export const sanitizeHttpUrl = (
url: string | null | undefined
): string | undefined => {
if (!url) {
return undefined;
}
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" ? url : undefined;
} catch (_err) {
return undefined;
}
};
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
/**
* Turns a `homeassistant://` deep link into an in-app path, or returns
* `undefined` when it does not point inside the frontend. Rewriting the scheme
* on its own is not enough: `homeassistant:///example.com` would become
* `//example.com`, which resolves to another origin.
*/
export const homeAssistantUrlToPath = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url)
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
: undefined;
/**
* Sanitizes a URL that may be either an external link or a `homeassistant://`
* deep link, returning something safe to bind to an `href`.
*/
export const sanitizeLinkUrl = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
@@ -18,6 +18,8 @@ export class HaProgressButton extends LitElement {
@property() appearance: Appearance = "accent";
@property() size: "xs" | "s" | "m" | "l" | "xl" = "m";
@property({ attribute: false }) public iconPath?: string;
@property() variant: "brand" | "danger" | "neutral" | "warning" | "success" =
@@ -32,6 +34,7 @@ export class HaProgressButton extends LitElement {
return html`
<ha-button
.appearance=${appearance}
.size=${this.size}
.disabled=${this.disabled}
.loading=${this.progress}
.variant=${
@@ -118,6 +121,12 @@ export class HaProgressButton extends LitElement {
width: 100%;
}
/* The icon lives in this shadow root, so callers cannot size it themselves. */
ha-button[size="xs"] ha-svg-icon[slot="start"],
ha-button[size="s"] ha-svg-icon[slot="start"] {
--mdc-icon-size: 16px;
}
ha-button.result::part(start),
ha-button.result::part(end),
ha-button.result::part(label),
+18 -36
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatCallyMonthYear,
formatDateMonthYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -56,13 +56,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -88,12 +91,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -163,9 +161,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -229,12 +225,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -308,24 +299,15 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = dateElement.focusedDate;
}
private _handleChange(ev: CustomEvent) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
this._focusDate = dateElement.focusedDate;
}
private _clickDateRangeChip(ev: Event) {
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatCallyMonthYear,
formatDateMonthYear,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -56,13 +57,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
public connectedCallback() {
@@ -71,12 +75,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -84,7 +83,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: this._pickerYear,
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -140,9 +139,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -185,12 +182,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -199,18 +191,9 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
const dateElement = ev.target as CalendarDate;
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = dateElement.focusedDate;
}
private _clear() {
+69 -21
View File
@@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { brandsUrl } from "../../util/brands-url";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
@@ -23,11 +26,21 @@ export class DialogDeviceReplaced
@state() private _open = false;
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
this._loadConfigEntries();
}
private async _loadConfigEntries(): Promise<void> {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
public closeDialog(): boolean {
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry> | undefined
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
secondary: area ? computeAreaName(area) : undefined,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas
).map(
(item) => html`
this.hass.areas,
this._configEntryLookup
).map((item) => {
const supportingText = [
item.area,
item.domainName,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button .deviceId=${item.deviceId}>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
${
item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: html`<ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>`
}
<span slot="headline">${item.name}</span>
${
item.secondary || item.isPrimary
? html`<span slot="supporting-text">
${[
item.secondary,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ")}
</span>`
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`
)}
`;
})}
</ha-list-base>
</ha-dialog>
`;
@@ -132,6 +176,10 @@ export class DialogDeviceReplaced
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property() public placeholder?: string;
@property({ attribute: "statistic-types" })
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.required=${this.required}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
use-top-label
.placeholder=${placeholder}
+10 -2
View File
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button-toolbar";
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
import type {
HaIconButtonToolbar,
HaIconButtonToolbarItem,
} from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "has-test" })
public hasTest = false;
@property({ attribute: false })
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
@property({ attribute: false }) public testing = false;
@property({ type: String }) public placeholder?: string;
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo") ||
changedProps.has("testing")
changedProps.has("testing") ||
changedProps.has("toolbarItems")
) {
this._updateToolbarButtons();
}
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
}
this._editorToolbar.items = [
...(this.toolbarItems ?? []),
...(this.hasTest && !this._isFullscreen
? [
{
+5 -6
View File
@@ -53,8 +53,10 @@ export class HaControlNumberButton extends LitElement {
});
private _boundedValue(value: number) {
const clamped = conditionalClamp(value, this.min, this.max);
return Math.round(clamped / this._step) * this._step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
const stepped = Math.round(value / this._step) * this._step;
return conditionalClamp(stepped, this.min, this.max);
}
private get _step() {
@@ -67,10 +69,7 @@ export class HaControlNumberButton extends LitElement {
private get _tenPercentStep() {
if (this.max == null || this.min == null) return this._step;
const range = this.max - this.min / 10;
if (range <= this._step) return this._step;
return Math.max(range / 10);
return Math.max((this.max - this.min) / 10, this._step);
}
private _handlePlusButton() {
+5 -7
View File
@@ -115,7 +115,9 @@ export class HaControlSlider extends LitElement {
}
steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
return this.boundedValue(Math.round(value / this.step) * this.step);
}
private _displayedValue(value: number) {
@@ -254,13 +256,9 @@ export class HaControlSlider extends LitElement {
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
} else if (e.code === "PageDown") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
} else {
const isRtl = mainWindow.document.dir === "rtl";
let multiplier = 1;
+17 -3
View File
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
if (!showError && !this.helper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
this.helper
? html`<span class="helper">${this.helper}</span>`
: nothing
}`
: this.helper
}
</ha-input-helper-text>`;
}
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
ha-input-helper-text .error,
ha-input-helper-text .helper {
display: block;
}
ha-input-helper-text .helper {
color: var(--secondary-text-color);
}
wa-popover {
--wa-space-l: 0;
@@ -131,6 +131,7 @@ 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}
+7 -9
View File
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
const documentationLink =
this._manifest?.is_built_in && this._value?.action
? documentationUrl(this.hass, `/actions/${this._value.action}`)
: this._manifest?.documentation;
const targetSelector =
serviceData && "target" in serviceData
? this._targetSelector(
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${
this._manifest
documentationLink
? html` <a
href=${
this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/actions/${this._value.action}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
+68
View File
@@ -0,0 +1,68 @@
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-split-panel")
export class HaSplitPanel extends SplitPanel {
static get styles(): CSSResultGroup {
return [
SplitPanel.styles,
css`
:host {
--divider-width: var(--ha-split-panel-divider-width, 2px);
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
--min: var(--ha-split-panel-min, 0);
--max: var(--ha-split-panel-max, 100%);
}
.divider {
background-color: var(--divider-color);
transition: background-color var(--ha-animation-duration-fast)
ease-out;
}
/* Grip affordance so the divider reads as draggable. The divider
already centers its children via flexbox, so keep this in flow.
Consumers slotting their own divider handle can hide it with
--ha-split-panel-grip-display: none. */
.divider::before {
content: "";
width: 2px;
height: var(--ha-space-8);
display: var(--ha-split-panel-grip-display, block);
border-radius: var(--ha-border-radius-pill, 9999px);
background-color: var(--secondary-text-color);
opacity: 0.5;
transition: opacity var(--ha-animation-duration-fast) ease-out;
}
/* In vertical orientation the divider is horizontal, so the grip pill
lies flat instead of standing upright. */
:host([orientation="vertical"]) .divider::before {
width: var(--ha-space-8);
height: 2px;
}
@media (hover: hover) {
:host(:not([disabled])) .divider:hover {
background-color: var(--primary-color);
}
:host(:not([disabled])) .divider:hover::before {
opacity: 1;
}
}
:host(:not([disabled])) .divider:focus-visible {
background-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-split-panel": HaSplitPanel;
}
}
+3
View File
@@ -228,6 +228,9 @@ export class HaThemeSettings extends LitElement {
}
static styles = css`
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
+27 -3
View File
@@ -31,6 +31,8 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -148,7 +150,12 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
return;
}
@@ -156,7 +163,12 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
return;
}
@@ -187,12 +199,15 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -253,6 +268,15 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
@@ -34,10 +34,12 @@ import {
browseMediaPlayer,
BROWSER_PLAYER,
MediaClassBrowserSettings,
searchMediaPlayer,
} from "../../data/media-player";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
isMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
// A player's tree can embed media sources, which resolve their own searches;
// everything else in it uses integration specific ids only the entity knows.
const searchEntityId =
this.entityId &&
this.entityId !== BROWSER_PLAYER &&
!isMediaSourceContentId(navigateId.media_content_id ?? "")
? this.entityId
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
const { result } = searchEntityId
? await searchMediaPlayer(
this.hass,
searchEntityId,
searchQuery,
navigateId.media_content_id,
navigateId.media_content_type,
mediaFilterClasses
)
: await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
+17 -2
View File
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
@property({ type: Boolean })
public vertical = false;
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
@property({ type: Boolean, attribute: "fixed-info-height" })
public fixedInfoHeight = false;
@property({ attribute: false })
public interactive = false;
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = { vertical: this.vertical };
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
};
return html`
<div
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px;
padding: 10px var(--ha-space-2);
}
.vertical.fixed-info-height {
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
gap: 2px;
--ha-tile-info-gap: 2px;
--ha-tile-info-primary-line-height: var(--ha-space-4);
--ha-tile-info-primary-min-height: var(--ha-space-8);
--ha-tile-info-min-height: var(--ha-space-12);
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+25 -4
View File
@@ -15,6 +15,11 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
*
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of the number of rendered lines. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
@@ -39,7 +44,7 @@ export class HaTileInfo extends LitElement {
return html`
<div class="info">
<slot name="primary" class="primary">
<span>${this.primary}</span>
<span part="primary">${this.primary}</span>
</slot>
${
this.secondaryLoading
@@ -59,6 +64,8 @@ export class HaTileInfo extends LitElement {
display: block;
width: 100%;
min-width: 0;
--tile-info-gap: var(--ha-tile-info-gap, 0);
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
@@ -71,6 +78,10 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-min-height: var(
--ha-tile-info-primary-min-height,
auto
);
--tile-info-primary-letter-spacing: var(
--ha-tile-info-primary-letter-spacing,
0.1px
@@ -106,28 +117,38 @@ export class HaTileInfo extends LitElement {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--tile-info-gap);
min-height: var(--tile-info-min-height);
}
span,
::slotted(*) {
.primary span,
::slotted([slot="primary"]),
.secondary span,
::slotted([slot="secondary"]) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.primary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-primary-font-size);
font-weight: var(--tile-info-primary-font-weight);
line-height: var(--tile-info-primary-line-height);
letter-spacing: var(--tile-info-primary-letter-spacing);
color: var(--tile-info-primary-color);
min-height: var(--tile-info-primary-min-height);
}
.secondary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-secondary-font-size);
font-weight: var(--tile-info-secondary-font-weight);
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
width: 100%;
}
.placeholder {
width: 140px;
+14 -2
View File
@@ -793,6 +793,17 @@ const findEnergyDataCollection = (
return (hass.connection as any)[key];
};
// The last-picked preset is remembered per energy collection, so each dashboard
// reopens on its own default period. Derived from the connection key so the read
// and write sides cannot drift apart.
export const getEnergyDefaultPeriodStorageKey = (
hass: HomeAssistant,
collectionKey?: string
): string => {
const [key] = convertCollectionKeyToConnection(hass, collectionKey);
return `energy-default-period-${key}`;
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
@@ -892,8 +903,9 @@ export const getEnergyDataCollection = (
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const preferredPeriod =
(localStorage.getItem(`energy-default-period-${key}`) as DateRange) ||
"today";
(localStorage.getItem(
getEnergyDefaultPeriodStorageKey(hass, options.key)
) as DateRange) || "today";
const period =
preferredPeriod === "today" && hour === "0" && !midnightRollover
? "yesterday"
+27 -5
View File
@@ -1,6 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
domain: string;
name: string;
config_flow: boolean;
documentation: string;
documentation?: string;
issue_tracker?: string;
dependencies?: string[];
after_dependencies?: string[];
@@ -78,11 +79,27 @@ export enum LogSeverity {
export type IntegrationLogPersistance = "none" | "once" | "permanent";
/**
* A custom integration supplies its own manifest, so its URLs are untrusted
* input. Strip them here, where manifests enter the frontend, so no consumer can
* turn one into a link that runs script.
*/
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
manifest: T
): T =>
manifest
? ({
...manifest,
documentation: sanitizeHttpUrl(manifest.documentation),
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
} as T)
: manifest;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
manifest.issue_tracker ||
sanitizeHttpUrl(manifest.issue_tracker) ||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
return hass
.callWS<IntegrationManifest[]>(params)
.then((manifests) => manifests.map(sanitizeManifest));
};
export const fetchIntegrationManifestsCollection = async (
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
});
const manifests: DomainManifestLookup = {};
for (const manifest of fetched) {
manifests[manifest.domain] = manifest;
manifests[manifest.domain] = sanitizeManifest(manifest);
}
setValue(manifests);
// One-time fetch — nothing to unsubscribe from
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
export const fetchIntegrationManifest = (
hass: HomeAssistant,
integration: string
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
) =>
hass
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
+23
View File
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMediaPlayer = (
hass: HomeAssistant,
entityId: string,
searchQuery: string,
mediaContentId?: string,
mediaContentType?: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_player/search_media",
entity_id: entityId,
search_query: searchQuery,
// the backend requires these two to be passed together, and JSON
// serialization drops them both when the current item is the root
media_content_id: mediaContentId,
media_content_type: mediaContentType,
media_filter_classes: mediaFilterClasses,
});
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
let progress = stateObj.attributes.media_position!;
+1 -5
View File
@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem } from "./media-player";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
export interface ResolvedMediaSource {
url: string;
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
+3
View File
@@ -397,6 +397,9 @@ 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;
}
+10
View File
@@ -0,0 +1,10 @@
import type { HomeAssistantApi } from "../types";
export const fetchSlug = (
api: HomeAssistantApi,
text: string
): Promise<{ slug: string }> =>
api.callWS<{ slug: string }>({
type: "slugify",
text,
});
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.manifest?.is_built_in) ||
!!this._params.manifest?.documentation;
const documentationLink = this._params.manifest?.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest.domain}`
)
: this._params.manifest?.documentation;
const dialogTitle = this._getDialogTitle();
const dialogSubtitle = this._getDialogSubtitle();
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
: nothing
}
${
showDocumentationLink && !this._loading && this._step
showDocumentationLink &&
documentationLink &&
!this._loading &&
this._step
? html`
<a
slot="headerActionItems"
class="help"
href=${
this._params.manifest!.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest!.domain}`
)
: this._params.manifest!.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer noopener"
>
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
</ha-button>
</ha-dialog-footer>
`;
case "external":
case "external": {
const externalUrl = sanitizeHttpUrl(this._step.url);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
${
externalUrl
? html`
<ha-button
slot="primaryAction"
href=${externalUrl}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
`
: nothing
}
</ha-dialog-footer>
`;
}
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
window.open(this.step.url);
// Opened without user interaction, so only ever follow an http(s) URL
const url = sanitizeHttpUrl(this.step.url);
if (url) {
window.open(url);
}
}
static get styles(): CSSResultGroup {
@@ -43,12 +43,19 @@ export class DialogHttpPendingConfig
private _interval?: number;
// This dialog must only be dismissed through its own footer buttons
// (confirm / revert / close). This flag is flipped right before such a
// button closes the dialog, so `closeDialog()` can refuse every other
// close request (navigation, back button, `closeAllDialogs`, …).
private _resolved = false;
public showDialog(params: HttpPendingConfigDialogParams): void {
this._params = params;
this._open = true;
this._busy = undefined;
this._error = undefined;
this._reverted = false;
this._resolved = false;
this._startCountdown();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
@@ -57,6 +64,12 @@ export class DialogHttpPendingConfig
}
public closeDialog(): boolean {
// Refuse programmatic close requests (navigation, back button,
// `closeAllDialogs`) so a pending HTTP config is never left silently
// unresolved. The dialog only closes once the user picks a footer action.
if (!this._resolved) {
return false;
}
this._open = false;
this._stopCountdown();
return true;
@@ -337,6 +350,9 @@ export class DialogHttpPendingConfig
}
private _notifyResolved(): void {
// Mark the dialog as user-resolved so `closeDialog()` is allowed to close
// it; every footer action calls this before setting `_open = false`.
this._resolved = true;
this._params?.onResolved?.();
// The form on Settings > System > Network may be mounted and showing
// stale state; let it know to refetch.
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
<div class="content">
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
</div>
${
this.stateObj.attributes.release_url
releaseUrl
? html`<div class="row">
<div class="key">
<a
href=${this.stateObj.attributes.release_url}
target="_blank"
rel="noreferrer"
>
<a href=${releaseUrl} target="_blank" rel="noreferrer">
${this._localize(
"ui.dialogs.more_info_control.update.release_announcement"
)}
+13
View File
@@ -1,3 +1,4 @@
import type { Connection } from "home-assistant-js-websocket";
import type { CSSResult } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
@@ -7,6 +8,7 @@ import { navigate } from "../common/navigate";
import type { CustomPanelInfo } from "../data/panel_custom";
import { baseEntrypointStyles } from "../resources/styles";
import { createCustomPanelElement } from "../util/custom-panel/create-custom-panel-element";
import { dropRealmCollections } from "../util/custom-panel/drop-realm-collections";
import { loadCustomPanel } from "../util/custom-panel/load-custom-panel";
import { setCustomPanelProperties } from "../util/custom-panel/set-custom-panel-properties";
@@ -29,8 +31,14 @@ window.loadES5Adapter = () => {
let panelEl: HTMLElement | undefined;
let initialized = false;
// Kept so we can clean up after ourselves on pagehide without depending on
// `window.parent.customPanel`, which `_cleanupPanel()` may already have deleted.
let connection: Connection | undefined;
function setProperties(properties) {
if (properties.hass?.connection) {
connection = properties.hass.connection;
}
if (!panelEl) {
return;
}
@@ -150,4 +158,9 @@ window.addEventListener("pagehide", () => {
while (document.body.lastChild) {
document.body.removeChild(document.body.lastChild);
}
// The connection is owned by the main window and outlives this realm, so any
// collection we created on it has to go with us.
if (connection) {
dropRealmCollections(connection);
}
});
+4 -26
View File
@@ -18,38 +18,15 @@
<meta name="referrer" content="same-origin" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="color-scheme" content="dark light" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_style_base.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-duration-normal, 250ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
height: 100vh;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
position: fixed;
top: 0;
left: 0;
@@ -61,7 +38,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: launch-screen;
user-select: none;
-webkit-user-select: none;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
@@ -98,7 +76,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+6 -2
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
}</span
>`
: this.localize?.("ui.init.loading") || "Loading data"
: this.localize?.("ui.init.loading") || "Loading..."
}
</p>`;
}
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
.migration-text {
white-space: pre-line;
}
.loading-text {
opacity: 0.66;
}
`;
}
+26 -13
View File
@@ -102,13 +102,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkDataBaseMigration();
}
// 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
) {
// 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) {
this.checkHttpPendingConfig();
}
if (
@@ -125,12 +124,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected update(changedProps: PropertyValues<this>) {
if (
this.hass?.states &&
this.hass.config &&
this.hass.services &&
this._databaseMigration === false
) {
const removingLaunchScreen =
!!this.hass?.states &&
!!this.hass.config &&
!!this.hass.services &&
this._databaseMigration === false;
if (removingLaunchScreen) {
this.render = this.renderHass;
this.update = super.update;
// partial-panel-resolver removes the launch screen after the first panel
@@ -138,6 +137,13 @@ 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>) {
@@ -256,6 +262,13 @@ 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;
}
+220 -81
View File
@@ -1,7 +1,20 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -10,7 +23,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
// Unique ID for updating or closing a specific toast without flickering.
id?: string;
message:
| string
@@ -34,105 +47,150 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _parameters?: ShowToastParams;
@state() private _notifications: Notification[] = [];
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@query(".stack") private _stack?: HTMLDivElement;
private _showDialogId = 0;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
public async showDialog(parameters: ShowToastParams) {
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
if (parameters.duration === 0) {
this._parameters = undefined;
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
return;
}
this._parameters = parameters;
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
await this.updateComplete;
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
this._showStack();
this._getToast(key)?.show();
}
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
if (ev.detail.reason === "dismiss") {
this._parameters?.dismiss?.();
this._getNotification(key)?.parameters.dismiss?.();
}
this._parameters = undefined;
this._removeNotification(key);
}
protected render() {
if (!this._parameters) {
if (!this._notifications.length) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -141,6 +199,7 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -155,19 +214,99 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
}
private _dismissClicked() {
this._toast?.hide("dismiss");
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || !this._stack) {
return;
}
// Top-layer order is order of entry — re-enter so we paint above any
// dialog backdrop opened since the stack was first shown.
if (this._stack.matches(":popover-open")) {
this._stack.hidePopover();
}
this._stack.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const selectedDomainName = this._params.selectedDomain
? domainToName(this.hass.localize, this._domain!)
: "";
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._domain}`)
: this._manifest?.documentation;
return html`
<ha-dialog
.open=${this._open}
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
}
)}
${
this._manifest?.is_built_in ||
this._manifest?.documentation
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/chips/ha-assist-chip";
import "../../../../../components/chips/ha-chip-set";
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"ui.panel.config.apps.dashboard.visit_app_page",
{
name: html`<a
href=${this._currentAddon.url!}
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
target="_blank"
rel="noreferrer"
>${getAppDisplayName(
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private get _pathWebui(): string | null {
const addon = this._currentAddon as HassioAddonDetails;
return addon.webui!.replace("[HOST]", document.location.hostname);
return (
sanitizeHttpUrl(
addon.webui!.replace("[HOST]", document.location.hostname)
) ?? null
);
}
private get _computeShowWebUI(): boolean | "" | null {
@@ -49,10 +49,7 @@ export default class HaAutomationActionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
!this.indent &&
(this.disabled ||
(this.action.enabled === false && !this.yamlMode)),
disabled: !this.indent && this.disabled,
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -128,8 +128,12 @@ class DialogAutomationSave
`;
}
private get _isDiscardDialog(): boolean {
return this._params?.onDiscard !== undefined;
}
protected _renderDiscard() {
if (!this._params?.onDiscard) {
if (!this._isDiscardDialog) {
return nothing;
}
return html`
@@ -324,10 +328,14 @@ class DialogAutomationSave
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
.disabled=${
!!this._params.config.alias &&
!this._isDiscardDialog &&
!this.isDirtyState
}
>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
this._params.config.alias && !this._isDiscardDialog
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
)}
@@ -56,10 +56,7 @@ export default class HaAutomationConditionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
!this.indent &&
(this.disabled ||
(this.condition.enabled === false && !this.yamlMode)),
disabled: !this.indent && this.disabled,
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/conditions/${this.condition.condition}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -35,6 +35,8 @@ import type HaAutomationSidebar from "./ha-automation-sidebar";
export const SIDEBAR_DEFAULT_WIDTH = 500;
export const PASTED_CONFIG_TOAST_ID = "pasted-config";
export const ManualEditorMixin = <TConfig>(
superClass: Constructor<LitElement>
) => {
@@ -237,6 +239,7 @@ export const ManualEditorMixin = <TConfig>(
this.pastedConfig = undefined;
showToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: "",
duration: 0,
});
@@ -37,7 +37,10 @@ import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "./ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
@@ -403,6 +406,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_toast_message"
),
@@ -48,11 +48,7 @@ export default class HaAutomationTriggerEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
this.disabled ||
("enabled" in this.trigger &&
this.trigger.enabled === false &&
!this.yamlMode),
disabled: this.disabled,
yaml: yamlMode,
card: !this.inSidebar,
})}
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/triggers/${this.trigger.trigger}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -68,6 +68,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
@@ -3,9 +3,10 @@ import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
@@ -23,11 +24,13 @@ import {
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { fetchSlug } from "../../../data/ws-slugify";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
const PREVIEW_DEBOUNCE_MS = 200;
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@@ -47,6 +50,10 @@ export class HaConfigEntityIdFormat extends LitElement {
@state() private _error?: string;
@state() private _preview?: string;
@state() private _previewError = false;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
@@ -57,12 +64,47 @@ export class HaConfigEntityIdFormat extends LitElement {
}
}
private _examples: Record<EntityIdPart, string> = {
area: "Living room",
device: "Thermostat",
entity: "Temperature",
floor: "Ground floor",
};
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(changedProps.has("_format") || changedProps.has("_localize")) &&
this._format
) {
if (changedProps.get("_format") === undefined) {
this._updatePreview();
} else {
this._debouncedUpdatePreview();
}
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
})
);
private _debouncedUpdatePreview = debounce(
() => this._updatePreview(),
PREVIEW_DEBOUNCE_MS
);
private async _updatePreview() {
const examples = this._examples(this._localize);
const fullName = this._format!.map((part) => examples[part])
.filter(Boolean)
.join(" ");
try {
const { slug } = await fetchSlug(this._api, fullName);
this._preview = slug;
this._previewError = false;
} catch (_err: any) {
this._previewError = true;
}
}
protected render() {
return html`
@@ -131,13 +173,20 @@ export class HaConfigEntityIdFormat extends LitElement {
}
private _renderPreview() {
const example = computeEntityIdFormatExample(this._format!, this._examples);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
${
this._previewError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.panel.config.entity_id_format.card.preview_error"
)}
</ha-alert>`
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
}
</div>
`;
}
@@ -96,6 +96,10 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
@@ -1263,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
const deviceActions: DeviceAction[] = [];
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
device.configuration_url
);
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url?.replace("homeassistant://", "/")
: device.configuration_url;
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
if (configurationUrl) {
deviceActions.push({
@@ -12,6 +12,7 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-svg-icon";
import "../../../../components/radio/ha-radio-group";
import "../../../../components/input/ha-input";
import { domainToName } from "../../../../data/integration";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { ConfigEntry } from "../../../../data/config_entries";
@@ -234,7 +235,7 @@ export class DialogEnergySolarSettings
},
this.hass.auth.data.hassUrl
)}
/>${entry.title}
/>${entry.title || domainToName(this.hass.localize, entry.domain)}
</div>
</ha-checkbox>`
)}
@@ -61,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
}
}
private get _requiredError(): string {
return this.hass.localize("ui.common.error_required");
}
protected render(): TemplateResult {
return html`
<p class="power-section-label">
@@ -119,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
{ unit: this._powerUnits?.join(", ") || "" }
)}
required
.invalid=${!this.powerConfig.stat_rate}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -138,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
.helper=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
)}
required
.invalid=${!this.powerConfig.stat_rate_inverted}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
}
${
// These two exclude each other, so they keep their clear button
// (and therefore no required marker) to stay swappable.
this.powerType === "two_sensors"
? html`
<ha-statistic-picker
@@ -157,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromPowerChanged}
.invalid=${!this.powerConfig.stat_rate_from}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
@@ -170,6 +184,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toPowerChanged}
.invalid=${!this.powerConfig.stat_rate_to}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
documentationURL = sanitizeHttpUrl(boardData.url);
imageURL = hardwareBrandsUrl(
{
category: "boards",
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
const newValue = { ...this._item };
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
newValue[day] = [...newValue[day]];
newValue[day][index] = {
...newValue[day][index],
from: value.from,
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
};
if (newDay === day) {
newValue[day] = [...newValue[day]];
newValue[day][index] = event;
} else {
newValue[day].splice(index, 1);
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
protected render(): TemplateResult {
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
this.flow.context.configuration_url
);
const configurationUrl = sanitizeLinkUrl(
this.flow.context.configuration_url
);
const documentationLink = this.manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
: this.manifest?.documentation;
return html`
<ha-integration-action-card
class=${classMap({
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
)}
</ha-button>
${
this.flow.context.configuration_url || this.manifest || attention
configurationUrl || documentationLink || attention
? html`<ha-dropdown
slot="header-button"
placement="bottom-end"
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.flow.context.configuration_url
configurationUrl
? html`<a
href=${this.flow.context.configuration_url.replace(
/^homeassistant:\/\//,
"/"
)}
href=${configurationUrl}
rel="noreferrer"
target=${
this.flow.context.configuration_url.startsWith(
"homeassistant://"
)
? "_self"
: "_blank"
configurationUrlIsHomeAssistant ? "_self" : "_blank"
}
>
<ha-dropdown-item>
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
: nothing
}
${
this.manifest
documentationLink
? html`<a
href=${
this.manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this.manifest.domain}`
)
: this.manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.domain
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
${
this._manifest
documentationLink
? html`
<a
slot="toolbar-icon"
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -14,11 +14,14 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import { animationStyles } from "../../../../../resources/theme/animations.globals";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
@@ -67,6 +70,8 @@ class ZHAConfigDashboard extends LitElement {
@state() private _error?: string;
@state() private _generatingBackup = false;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (!this.hass) {
@@ -355,17 +360,18 @@ class ZHAConfigDashboard extends LitElement {
"ui.panel.config.zha.configuration_page.download_backup_description"
)}
</span>
<ha-button
<ha-progress-button
appearance="plain"
slot="end"
size="s"
.iconPath=${mdiDownload}
.progress=${this._generatingBackup}
@click=${this._createAndDownloadBackup}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup_action"
)}
</ha-button>
</ha-progress-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
@@ -408,30 +414,32 @@ class ZHAConfigDashboard extends LitElement {
this._configuration = await fetchZHAConfiguration(this.hass!);
}
private async _createAndDownloadBackup(): Promise<void> {
private async _createAndDownloadBackup(
ev: HASSDomCurrentTargetEvent<HaProgressButton>
): Promise<void> {
// Captured up front: currentTarget is null once the first await resolves.
const button = ev.currentTarget;
let backup_and_metadata: ZHANetworkBackupAndMetadata;
// Reading the backup from the coordinator can take 5-30 seconds.
this._generatingBackup = true;
try {
backup_and_metadata = await createZHANetworkBackup(this.hass!);
} catch (err: any) {
button.actionError();
showAlertDialog(this, {
title: "Failed to create backup",
title: this.hass.localize(
"ui.panel.config.zha.configuration_page.backup_failed"
),
text: err.message,
warning: true,
});
return;
} finally {
this._generatingBackup = false;
}
if (!backup_and_metadata.is_complete) {
await showAlertDialog(this, {
title: "Backup is incomplete",
text: "A backup has been created but it is incomplete and cannot be restored. This is a coordinator firmware limitation.",
});
}
const backupJSON: string =
"data:text/plain;charset=utf-8," +
encodeURIComponent(JSON.stringify(backup_and_metadata.backup, null, 4));
const backupTime: Date = new Date(
Date.parse(backup_and_metadata.backup.backup_time)
);
@@ -441,7 +449,23 @@ class ZHAConfigDashboard extends LitElement {
basename = `Incomplete ${basename}`;
}
fileDownload(backupJSON, `${basename}.json`);
const blob = new Blob(
[JSON.stringify(backup_and_metadata.backup, null, 4)],
{ type: "application/json" }
);
fileDownload(URL.createObjectURL(blob), `${basename}.json`);
button.actionSuccess();
if (!backup_and_metadata.is_complete) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.zha.configuration_page.backup_incomplete_title"
),
text: this.hass.localize(
"ui.panel.config.zha.configuration_page.backup_incomplete_text"
),
});
}
}
private _openOptionFlow() {
@@ -517,10 +541,6 @@ class ZHAConfigDashboard extends LitElement {
--md-item-overflow: visible;
}
ha-button[size="s"] ha-svg-icon {
--mdc-icon-size: 16px;
}
.network-status div.heading {
display: flex;
align-items: center;
@@ -11,6 +11,7 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import { groupBy } from "../../../../../common/util/group-by";
import "../../../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
@@ -154,8 +155,9 @@ class ZWaveJSNodeConfig extends LitElement {
device_database: html`<a
rel="noreferrer noopener"
href=${
this._nodeMetadata?.device_database_url ||
"https://devices.zwave-js.io"
sanitizeHttpUrl(
this._nodeMetadata?.device_database_url
) || "https://devices.zwave-js.io"
}
target="_blank"
>${this.hass.localize(
+8 -4
View File
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
// Build description with learn more link if available
const descriptionWithLink = preview_feature.learn_more_url
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
<div class="card-actions">
<div>
${
preview_feature.feedback_url
feedbackUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.feedback_url}
href=${feedbackUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
: nothing
}
${
preview_feature.report_issue_url
reportIssueUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.report_issue_url}
href=${reportIssueUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
/** Compares the host, so a URL that merely contains ours does not pass. */
const isOfficialDocumentationUrl = (url: string): boolean => {
try {
return new URL(url).hostname === "www.home-assistant.io";
} catch (_err) {
return false;
}
};
@customElement("dialog-system-log-detail")
class DialogSystemLogDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
this._manifest &&
(this._manifest.is_built_in ||
// Custom components with our official docs should not link to our docs
!this._manifest.documentation.includes("://www.home-assistant.io"));
(!!this._manifest.documentation &&
!isOfficialDocumentationUrl(this._manifest.documentation)));
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
const title = this.hass.localize("ui.panel.config.logs.details", {
level: html`<span class=${item.level}
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
${
!this._manifest ||
// Can happen with custom integrations
!showDocumentation
!showDocumentation ||
!documentationLink
? ""
: html`
(<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
+138 -11
View File
@@ -3,6 +3,8 @@ import type { CSSResultGroup, PropertyValues } from "lit";
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,
@@ -24,7 +26,10 @@ import type {
HttpConfig,
HttpConfigWithMeta,
} from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -34,7 +39,16 @@ const SCHEMA = memoizeOne(
{
name: "server_port",
required: true,
selector: { number: { min: 1, max: 65535, mode: "box" } },
selector: {
number: {
min: 1,
max: 65535,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_port"
),
},
},
},
{
name: "ssl",
@@ -114,7 +128,16 @@ const SCHEMA = memoizeOne(
{
name: "login_attempts_threshold",
required: true,
selector: { number: { min: -1, max: 1000, mode: "box" } },
selector: {
number: {
min: -1,
max: 1000,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_login_attempts_threshold"
),
},
},
},
],
},
@@ -129,7 +152,7 @@ const SCHEMA = memoizeOne(
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_PATTERN,
pattern: `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`,
validation_message: localize(
"ui.panel.config.network.http.invalid_host"
),
@@ -167,6 +190,10 @@ class HaConfigHttpForm extends LitElement {
@state() private _activeConfigType?: ActiveConfigType;
// The built-in default config as reported by core; used to show the default
// port in the helper text instead of a hard-coded value.
@state() private _default?: HttpConfigWithMeta;
// A pending config that was reverted/failed and kept only for display.
@state() private _revertedPending?: HttpConfigWithMeta;
@@ -201,6 +228,8 @@ class HaConfigHttpForm extends LitElement {
const portChanged =
!!this._stable && this._config?.server_port !== this._stable.server_port;
const hasListenAddresses = !!this._config?.server_host?.some(Boolean);
return html`
<ha-card
outlined
@@ -255,6 +284,17 @@ class HaConfigHttpForm extends LitElement {
`
: nothing
}
${
hasListenAddresses
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.server_host_warning"
)}
</ha-alert>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -309,12 +349,16 @@ class HaConfigHttpForm extends LitElement {
private async _fetchConfig(): Promise<void> {
try {
const { stable, pending, active_config_type } = await fetchHttpConfig(
this.hass
);
const {
stable,
pending,
active_config_type,
default: defaultConfig,
} = await fetchHttpConfig(this.hass);
this._stable = stable;
this._config = { ...stable };
this._activeConfigType = active_config_type;
this._default = defaultConfig;
// An active trial pending (no error) is handled by the global
// confirm/revert dialog. A pending carrying an error was reverted or
// failed to apply and is kept only so we can surface it here.
@@ -351,6 +395,12 @@ class HaConfigHttpForm extends LitElement {
if ("type" in schema && schema.type === "expandable") {
return "";
}
if (schema.name === "server_port") {
return this.hass.localize(
"ui.panel.config.network.http.helpers.server_port",
{ port: this._default?.server_port ?? 8123 }
);
}
return (
this.hass.localize(
`ui.panel.config.network.http.helpers.${schema.name}` as any
@@ -365,6 +415,63 @@ class HaConfigHttpForm extends LitElement {
this._showNoChanges = false;
}
// Build a link to the new address for an address-changing restart, so the
// user (still on the old address) can jump to it once Home Assistant is back.
// Best-effort: skip Home Assistant Cloud remote UI (Nabu Casa), and skip when
// the current page is not on the old port — that usually means a reverse
// proxy, where swapping the port would point at the wrong place. Even when
// shown, the new address may not be reachable (e.g. a firewall).
private _newAddressUrl(): string | undefined {
if (!this._stable || !this._config) {
return undefined;
}
const loc = mainWindow.location;
if (loc.hostname.endsWith("nabu.casa")) {
return undefined;
}
const oldHttps = !!this._stable.ssl_certificate;
const newHttps = !!this._config.ssl_certificate;
const oldPort = this._stable.server_port ?? (oldHttps ? 443 : 80);
const newPort = this._config.server_port ?? (newHttps ? 443 : 80);
// The reachable address only changes when the scheme or the port changes.
if (oldHttps === newHttps && oldPort === newPort) {
return undefined;
}
const currentPort = loc.port
? Number(loc.port)
: loc.protocol === "https:"
? 443
: 80;
if (currentPort !== oldPort) {
return undefined;
}
const url = new URL(loc.origin);
url.protocol = newHttps ? "https:" : "http:";
url.port = String(newPort);
return url.toString();
}
private _showNewAddress(url: string): void {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.network.http.restart_address.title"
),
text: html`
<p>
${this.hass.localize(
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} target="_blank" rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
)}
</p>
`,
});
}
private async _save(): Promise<void> {
if (!this._config || !this._stable) {
return;
@@ -393,24 +500,44 @@ class HaConfigHttpForm extends LitElement {
return;
}
// Capture the new address before the restart drops the connection.
const newAddressUrl = this._newAddressUrl();
this._saving = true;
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
// Drop empty entries from multi-value fields, and omit the field entirely
// once it is empty so the backend applies its default. Otherwise a cleared
// "IP address to bind to" would submit [""] / [], which binds to nothing.
const config = Object.fromEntries(
Object.entries(this._config).map(([key, value]) => {
if (Array.isArray(value)) {
const filtered = value.filter(Boolean);
return [key, filtered.length ? filtered : undefined];
}
return [key, value];
})
) as HttpConfig;
try {
const result = await saveHttpConfig(this.hass, this._config);
const result = await saveHttpConfig(this.hass, config);
if (!result.restart) {
this._showNoChanges = true;
} else if (newAddressUrl) {
// restart === true: a restart is in flight. The reply usually races
// with the connection drop; if we do reach this branch, offer the new
// address so the user can follow along.
this._showNewAddress(newAddressUrl);
}
// restart === true: a restart is in flight. The reply usually races with
// the connection drop; if we do reach this branch, the disconnected
// overlay will appear in moments. Leave the form as is.
} catch (err: any) {
// The restart kills the WS connection before the ack — that's expected.
if (
err?.error?.code === ERR_CONNECTION_LOST ||
err === ERR_CONNECTION_LOST
) {
if (newAddressUrl) {
this._showNewAddress(newAddressUrl);
}
return;
}
this._handleSaveError(err);
@@ -15,6 +15,8 @@ import type { HaInputCopy } from "../../../components/input/ha-input-copy";
import type { CloudStatus } from "../../../data/cloud";
import { fetchCloudStatus } from "../../../data/cloud";
import { saveCoreConfig } from "../../../data/core";
import type { HttpConfigWithMeta } from "../../../data/http";
import { fetchHttpConfig } from "../../../data/http";
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
@@ -34,6 +36,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
@state() private _internal_url = "";
// Stable HTTP config from core; used to build a realistic internal URL
// placeholder (scheme from SSL, port from the configured/default port).
@state() private _httpConfig?: HttpConfigWithMeta;
@state() private _httpDefault?: HttpConfigWithMeta;
@state() private _cloudStatus?: CloudStatus | null;
@state() private _showCustomExternalUrl = false;
@@ -97,6 +105,28 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
}
}
// Build realistic URL placeholders: use https when an SSL certificate is
// configured, and only show the port when it differs from the scheme
// default (80 for http, 443 for https).
const useHttps = this._httpConfig?.ssl_certificate ? true : httpUseHttps;
const placeholderPort =
this._httpConfig?.server_port ?? this._httpDefault?.server_port;
const placeholderParts = {
protocol: useHttps ? "https" : "http",
port:
placeholderPort && placeholderPort !== (useHttps ? 443 : 80)
? `:${placeholderPort}`
: "",
};
const internalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.internal_url_placeholder",
placeholderParts
);
const externalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.external_url_placeholder",
placeholderParts
);
return html`
<ha-card
outlined
@@ -157,7 +187,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="external_url"
type="url"
.maskedToggle=${!(this._showCustomExternalUrl && canEdit)}
placeholder="https://example.duckdns.org:8123"
.placeholder=${externalUrlPlaceholder}
.value=${externalUrl}
.maskedValue=${
this._showCustomExternalUrl && canEdit
@@ -260,9 +290,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="internal_url"
.maskedToggle=${!(this._showCustomInternalUrl && canEdit)}
type="url"
placeholder=${this.hass.localize(
"ui.panel.config.url.internal_url_placeholder"
)}
.placeholder=${internalUrlPlaceholder}
.value=${internalUrl}
.maskedValue=${
this._showCustomInternalUrl && canEdit
@@ -325,6 +353,14 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
this._cloudStatus = null;
}
this._fetchUrls();
// Best-effort: the placeholder still works without it, just without a port.
fetchHttpConfig(this.hass).then(
({ stable, default: defaultConfig }) => {
this._httpConfig = stable;
this._httpDefault = defaultConfig;
},
() => undefined
);
}
private _toggleCloud(ev: Event) {
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-alert";
import "../../../components/ha-dialog";
import "../../../components/ha-button";
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
return nothing;
}
const learnMoreUrlIsHomeAssistant =
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
this._issue.learn_more_url
);
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
const dialogTitle =
this.hass.localize(
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
}
</ha-button>
${
this._issue.learn_more_url
learnMoreUrl
? html`
<ha-button
slot="primaryAction"
appearance="filled"
rel="noopener noreferrer"
href=${
learnMoreUrlIsHomeAssistant
? this._issue.learn_more_url.replace(
"homeassistant://",
"/"
)
: this._issue.learn_more_url
}
href=${learnMoreUrl}
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
@click=${
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
import "../../../components/ha-alert";
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
} else if (info.type === "failed") {
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
value = html`
<span class="error">${info.error}</span>${
!info.more_info
!moreInfoUrl
? ""
: html`
<a
href=${info.more_info}
href=${moreInfoUrl}
target="_blank"
rel="noreferrer noopener"
>
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
`);
}
if (domain !== "homeassistant") {
// No target, so an in-app path is also a valid destination here
const manageUrl =
sanitizeHttpUrl(domainInfo.manage_url) ??
sanitizeNavigationPath(domainInfo.manage_url);
sections.push(html`
<div class="card-header">
<h3>${domainToName(this.hass.localize, domain)}</h3>
${
!domainInfo.manage_url
!manageUrl
? ""
: html`
<ha-button
appearance="plain"
size="s"
class="manage"
href=${domainInfo.manage_url}
href=${manageUrl}
>
${this.hass.localize(
"ui.panel.config.info.system_health.manage"
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
<ha-md-list>
${this._setups?.map((setup) => {
const manifest = this._manifests && this._manifests[setup.domain];
const docLink = manifest
? manifest.is_built_in
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
: manifest.documentation
: "";
const docLink =
(manifest
? manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${manifest.domain}`
)
: manifest.documentation
: "") || "";
const setupSeconds = setup.seconds?.toFixed(2);
return html`
@@ -33,7 +33,10 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showEditorToast } from "../automation/editor-toast";
import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import { ManualEditorMixin } from "../automation/ha-manual-editor-mixin";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "../automation/ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "../automation/styles";
import "./ha-script-fields";
@@ -335,6 +338,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.script.editor.paste_toast_message"
),
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
@@ -17,6 +18,8 @@ import {
isTemplate,
} from "../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
import { extractSearchParam } from "../../../../common/url/search-params";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
`
: html`
<a
href=${resolved.url}
href=${ifDefined(
sanitizeHttpUrl(resolved.url) ??
sanitizeNavigationPath(resolved.url)
)}
target="_blank"
rel="noreferrer"
><ha-button>
+356 -214
View File
@@ -1,17 +1,27 @@
import {
mdiRestore,
mdiTrashCanOutline,
mdiViewSplitHorizontal,
mdiViewSplitVertical,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { customElement, property, state } from "lit/decorators";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-expansion-panel";
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
import "../../../../components/ha-label";
import "../../../../components/ha-spinner";
import "../../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_functions", path: "/template-functions/" },
];
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
const DEFAULT_SPLIT_POSITION = 50;
type SplitOrientation = "horizontal" | "vertical";
@customElement("tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _error?: string;
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@state() private _descriptionExpanded = false;
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("ha-tip") private _editorTip?: HTMLElement;
@state() private _splitOrientation: SplitOrientation = "horizontal";
private _template = "";
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
super.connectedCallback();
if (this._template && !this._unsubRenderTemplate) {
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeTemplate();
this._tipResizeObserver?.disconnect();
this._tipResizeObserver = undefined;
}
protected firstUpdated() {
if (localStorage && localStorage["panel-dev-template-template"]) {
this._template = localStorage["panel-dev-template-template"];
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
this._template = localStorage[STORAGE_KEY_TEMPLATE];
} else {
this._template = DEMO_TEMPLATE;
}
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
this._splitPosition = parsed;
}
}
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
this._splitOrientation = "vertical";
}
this._subscribeTemplate();
this._observeTipHeight();
this._inited = true;
}
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
: "dict"
: type;
const editorCard = this._renderEditorCard();
const resultCard = this._renderResultCard(type, resultType);
// On narrow viewports side-by-side is too cramped, so force the (still
// resizable) stacked layout and hide the orientation toggle.
const orientation = this.narrow ? "vertical" : this._splitOrientation;
return html`
<div class="content">
<div class="about">
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.about"
)}
outlined
.expanded=${this._descriptionExpanded}
@expanded-changed=${this._expandedChanged}
>
<div class="description">
<p>
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
</div>
</ha-expansion-panel>
</div>
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
>
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._restoreDemo}>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.reset"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
this._templateResult
? html`<pre
class="rendered ${classMap({
[resultType]: resultType,
})}"
>
<ha-split-panel
class="panes ${orientation === "vertical" ? "vertical" : ""}"
.position=${this._splitPosition}
.orientation=${orientation}
snap="50%"
@wa-reposition=${this._splitRepositioned}
>
<div slot="start" class="pane">${editorCard}</div>
<div slot="end" class="pane">${resultCard}</div>
${this.narrow ? nothing : this._renderOrientationToggle()}
</ha-split-panel>
`;
}
private _renderOrientationToggle() {
const label = this.hass.localize(
this._splitOrientation === "vertical"
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
: "ui.panel.config.tools.tabs.templates.layout_stacked"
);
return html`
<button
type="button"
slot="divider"
class="divider-toggle"
.title=${label}
aria-label=${label}
@mousedown=${stopPropagation}
@touchstart=${stopPropagation}
@click=${this._toggleOrientation}
>
<ha-svg-icon
.path=${
this._splitOrientation === "vertical"
? mdiViewSplitVertical
: mdiViewSplitHorizontal
}
></ha-svg-icon>
</button>
`;
}
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
// copy, search and fullscreen buttons; the trailing divider separates them.
private _editorToolbarItems = memoizeOne(
(
localize: HomeAssistant["localize"]
): (HaIconButtonToolbarItem | string)[] => [
{
id: "restore-demo",
label: localize("ui.panel.config.tools.tabs.templates.reset"),
path: mdiRestore,
action: () => this._restoreDemo(),
},
{
id: "clear",
label: localize("ui.common.clear"),
path: mdiTrashCanOutline,
action: () => this._clear(),
},
"divider",
]
);
private _renderEditorCard() {
return html`
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
${
this.narrow
? nothing
: html`
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
`
}
</ha-card>
`;
}
private _renderResultCard(type: string, resultType: string) {
const showEmptyState =
!this._error && !this._rendering && !this._template?.trim();
return html`
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
showEmptyState
? html`<div class="empty">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_placeholder"
)}
</div>`
: this._templateResult
? html`
<ha-label dense>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</ha-label>
<pre class="rendered">
${
type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result
}</pre>
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</p>
${
this._templateResult.listeners.time
? html`
@@ -316,109 +410,179 @@ ${
)}
</span>`
: nothing
}`
}
`
: nothing
}
</div>
</ha-card>
</div>
}
</div>
</ha-card>
`;
}
private _observeTipHeight() {
if (!this._editorTip || this._tipResizeObserver) {
return;
}
this._tipResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ??
entries[0]?.contentRect.height;
if (height) {
this.style.setProperty("--tip-height", `${height}px`);
}
});
this._tipResizeObserver.observe(this._editorTip);
private _splitRepositioned(ev: Event) {
this._splitPosition = (ev.target as HaSplitPanel).position;
this._storeSplitPosition();
}
private _expandedChanged(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
) {
this._descriptionExpanded = ev.detail.expanded;
private _toggleOrientation() {
this._splitOrientation =
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
if (this._inited) {
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
}
}
private _storeSplitPosition = debounce(
() => {
if (!this._inited) {
return;
}
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
:host {
user-select: none;
display: flex;
flex-direction: column;
height: 100%;
}
.content {
gap: var(--ha-space-4);
.about {
flex: none;
padding: var(--ha-space-4);
}
.content:has(ha-expansion-panel) {
padding-bottom: 0;
}
.content.horizontal {
--panel-header-height: calc(
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
var(--ha-font-size-m) + 1px + 2px
);
--description-pane-height: calc(
var(--ha-space-4) + 48px +
(
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
var(--ha-space-1) * 2
) *
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
);
--card-header-height: calc(
var(--ha-space-3) + var(--ha-space-4) +
var(--ha-line-height-expanded) *
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
);
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
--tip-height-minimal: calc(
var(--mdc-icon-size, 24px) + var(--ha-space-4)
);
--edit-pane-height: calc(
100vh - var(--panel-header-height) - var(
--description-pane-height
) - var(--ha-space-4) *
2
);
--code-mirror-max-height: calc(
var(--edit-pane-height) - var(--card-header-height) +
var(--ha-space-2) - var(--card-actions-height) - var(
--tip-height,
var(--tip-height-minimal)
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
2
);
.about a {
color: var(--primary-color);
}
.panes {
flex: 1;
min-height: 0;
box-sizing: border-box;
padding: var(--ha-space-4);
--ha-split-panel-min: 20%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
/* On wide viewports we slot our own handle (the orientation toggle)
into the divider, so hide the default grip. On narrow there is no
toggle, so keep the default grip as the resize affordance. */
:host(:not([narrow])) .panes {
--ha-split-panel-grip-display: none;
}
/* Orientation toggle that lives on the divider. Clicking it toggles
orientation; resizing is done by dragging the divider elsewhere. */
.divider-toggle {
position: relative;
z-index: 1;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 50%;
background-color: var(--card-background-color);
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 16px;
transition:
color var(--ha-animation-duration-fast) ease-out,
border-color var(--ha-animation-duration-fast) ease-out;
}
@media (hover: hover) {
.divider-toggle:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
.divider-toggle:focus-visible {
outline: none;
color: var(--primary-color);
border-color: var(--primary-color);
}
.pane {
display: flex;
min-width: 0;
height: 100%;
box-sizing: border-box;
contain: size;
}
.pane[slot="start"] {
padding-inline-end: var(--ha-space-4);
}
.pane[slot="end"] {
padding-inline-start: var(--ha-space-4);
}
.panes.vertical .pane[slot="start"] {
padding-inline-end: 0;
padding-block-end: var(--ha-space-4);
}
.panes.vertical .pane[slot="end"] {
padding-inline-start: 0;
padding-block-start: var(--ha-space-4);
}
.pane ha-card {
flex: 1;
min-width: 0;
}
ha-card {
margin-bottom: var(--ha-space-4);
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
.edit-pane .card-content {
flex: 1;
min-height: 0;
display: flex;
}
.edit-pane ha-code-editor {
flex: 1;
min-height: 0;
width: 100%;
--code-mirror-height: 100%;
}
.render-pane .card-content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.edit-pane {
direction: var(--direction);
}
.edit-pane a {
color: var(--primary-color);
}
.content.horizontal > * {
width: 50%;
margin-bottom: 0px;
}
.render-spinner {
position: absolute;
top: var(--ha-space-2);
@@ -428,10 +592,24 @@ ${
}
ha-alert {
margin-bottom: var(--ha-space-2);
display: block;
}
.render-pane ha-label {
align-self: flex-start;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
.rendered {
font-family: var(--ha-font-family-code);
-webkit-font-smoothing: var(--ha-font-smoothing);
@@ -439,6 +617,7 @@ ${
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-2);
margin-top: 0;
margin-bottom: 0;
@@ -447,7 +626,7 @@ ${
p,
ul {
margin-block-end: 0;
margin-block: 0;
}
.description > p {
margin-block-start: 0;
@@ -468,26 +647,6 @@ ${
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
}
.content.horizontal .render-pane .card-content {
overflow: auto;
max-height: calc(
var(--code-mirror-max-height) +
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
);
}
.content.horizontal .render-pane {
overflow: hidden;
padding-bottom: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.all_listeners {
color: var(--warning-color);
}
@@ -507,19 +666,6 @@ ${
background-color: var(--secondary-background-color);
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;
}
}
.card-actions {
display: flex;
}
.card-actions > ha-button:last-child {
margin-inline-start: auto;
}
`,
];
}
@@ -615,7 +761,7 @@ ${
if (!this._inited) {
return;
}
localStorage["panel-dev-template-template"] = this._template;
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
}
private async _restoreDemo() {
@@ -631,7 +777,7 @@ ${
}
this._template = DEMO_TEMPLATE;
this._subscribeTemplate();
delete localStorage["panel-dev-template-template"];
delete localStorage[STORAGE_KEY_TEMPLATE];
}
private async _clear() {
@@ -647,12 +793,8 @@ ${
}
this._unsubscribeTemplate();
this._template = "";
// Reset to empty result. Setting to 'undefined' results in a different visual
// behaviour compared to manually emptying the template input box.
this._templateResult = {
result: "",
listeners: { all: false, entities: [], domains: [], time: false },
};
// An empty template shows the placeholder empty state.
this._templateResult = undefined;
}
}
+14
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
render() {
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
return html`
<hass-error-screen
.hass=${this.hass}
.narrow=${this.narrow}
error="Unable to load iframes with this URL."
rootnav
></hass-error-screen>
`;
}
if (
location.protocol === "https:" &&
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
+6
View File
@@ -321,6 +321,9 @@ export class HaPanelLogbook extends LitElement {
"entity_id",
"state",
"event_type",
"name",
"message",
"source",
"context_id",
"context_user_id",
"context_event_type",
@@ -339,6 +342,9 @@ export class HaPanelLogbook extends LitElement {
d.entity_id || "",
csvSafeString(d.state),
csvSafeString(d.attributes?.event_type),
csvSafeString(d.name),
csvSafeString(d.message),
csvSafeString(d.source),
d.context_id || "",
d.context_user_id || "",
csvSafeString(d.context_event_type),
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
${
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${Boolean(this._hasCardAction)}
@action=${this._handleAction}
>
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
</ha-tile-icon>
<ha-tile-info
slot="info"
class=${ifDefined(
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
)}
.primary=${primary}
.secondary=${secondary}
></ha-tile-info>
@@ -818,6 +825,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
justify-content: center;
color: white;
}
ha-tile-info.twoline::part(primary) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
overflow-wrap: anywhere;
}
`,
];
}
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
throw new Error("URL required");
}
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = config;
}
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: ShortcutCardConfig;
private _navInfo = new NavigationPathInfoController(this);
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
const style = color ? { "--tile-color": color } : {};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
<ha-tile-container
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config.hold_action),
@@ -296,6 +296,10 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
await this._getStatisticsMetaData(this._entityIds);
}
await this._getStatistics();
if (!this.isConnected) {
this._interval = undefined;
return;
}
// statistics are created every hour
if (!this._config?.energy_date_selection) {
this._interval = window.setInterval(
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: TileCardConfig;
@state() private _featureContext: LovelaceCardFeatureContext = {};
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const hasImage = Boolean(imageUrl);
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config!.hold_action),
+2 -1
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { forwardHaptic } from "../../../data/haptics";
@@ -133,7 +134,7 @@ export const handleAction = async (
break;
case "url": {
if (actionConfig.url_path) {
window.open(actionConfig.url_path);
window.open(sanitizeUrl(actionConfig.url_path));
} else {
showToast(node, {
message: hass.localize("ui.panel.lovelace.cards.actions.no_url"),
@@ -64,6 +64,7 @@ import {
CompareMode,
downloadEnergyData,
getEnergyDataCollection,
getEnergyDefaultPeriodStorageKey,
} from "../../../data/energy";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
@@ -544,7 +545,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
private _presetSelected(ev) {
localStorage.setItem(
`energy-default-period-_${this.collectionKey || "energy"}`,
getEnergyDefaultPeriodStorageKey(this.hass, this.collectionKey),
RANGE_KEYS[ev.detail.index]
);
}
@@ -123,8 +123,9 @@ export class HuiCardPicker extends LitElement {
};
const fuse = new Fuse(cards, options);
cards = fuse.search(filter).map((result) => result.item);
return cardElements.filter((cardElement: CardElement) =>
cards.includes(cardElement.card)
return cardElements.filter(
(cardElement: CardElement) =>
cards.includes(cardElement.card) && cardElement.card.type !== "manual"
);
}
);
@@ -140,7 +141,9 @@ export class HuiCardPicker extends LitElement {
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
!cardElement.card.isCustom && !cardElement.card.isEnergy
!cardElement.card.isCustom &&
!cardElement.card.isEnergy &&
!(cardElement.card.type === "manual")
)
);
@@ -151,6 +154,12 @@ export class HuiCardPicker extends LitElement {
)
);
private _manualCard = memoizeOne((cardElements: CardElement[]): CardElement =>
cardElements.find(
(cardElement: CardElement) => cardElement.card.type === "manual"
)!
);
private _favoriteCards(): CardElement[] {
return this._favorites
.map((key) => this._favoriteElement(key))
@@ -160,9 +169,10 @@ export class HuiCardPicker extends LitElement {
// A preview is a live DOM node, so every section a card shows up in needs
// its own element.
private _toCardElement(card: Card): CardElement {
const config = card.type === "manual" ? { type: "" } : undefined;
return {
card,
element: html`${until(this._renderCardElement(card), SPINNER)}`,
element: html`${until(this._renderCardElement(card, config), SPINNER)}`,
};
}
@@ -194,6 +204,7 @@ export class HuiCardPicker extends LitElement {
const othersCards = this._otherCards(this._cards);
const energyCardsItems = this._energyCards(this._cards);
const customCardsItems = this._customCards(this._cards);
const manualCard = this._manualCard(this._cards);
return html`
<ha-input-search
@@ -334,25 +345,7 @@ export class HuiCardPicker extends LitElement {
}
`
}
<div class="cards-container">
<div
class="card manual"
@click=${this._cardPicked}
.config=${{ type: "" }}
>
<div class="card-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.manual`
)}
</div>
<div class="preview description">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.manual_description`
)}
</div>
<ha-ripple></ha-ripple>
</div>
</div>
<div class="cards-container">${this._renderCard(manualCard, true)}</div>
</div>
`;
}
@@ -443,6 +436,13 @@ export class HuiCardPicker extends LitElement {
)
);
}
cards = cards.concat({
type: "manual",
name: this.hass!.localize("ui.panel.lovelace.editor.card.generic.manual"),
description: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.manual_description"
),
});
this._cards = cards.map((card: Card) => this._toCardElement(card));
this._suggestedCards = cards
.filter((card: Card) => card.isSuggested)
@@ -481,12 +481,12 @@ export class HuiCardPicker extends LitElement {
}
);
private _renderCard(cardElement: CardElement): TemplateResult {
private _renderCard(cardElement: CardElement, wide = false): TemplateResult {
const key = cardKey(cardElement.card);
const favorite = this._favorites.includes(key);
return html`
<div class="card" tabindex="0">
<div class="card ${classMap({ "full-width": wide })}" tabindex="0">
${cardElement.element}
<ha-icon-button
class="favorite ${classMap({ selected: favorite })}"
@@ -832,7 +832,7 @@ export class HuiCardPicker extends LitElement {
);
}
.manual {
.full-width {
max-width: none;
grid-column: 1 / -1;
}
@@ -54,7 +54,7 @@ export class HuiNumericInputCardFeatureEditor
}
const data: NumericInputCardFeatureConfig = {
style: "buttons",
style: "slider",
...this._config,
};
@@ -500,13 +500,20 @@ export class HuiStatisticsGraphCardEditor
// update card config with updated entity config
const index = this._subElementEditorConfig!.index!;
const oldEntityConfig = this._config!.entities[index];
const oldEntityId =
typeof oldEntityConfig === "string"
? oldEntityConfig
: oldEntityConfig?.entity;
const newEntities = [...this._config!.entities];
newEntities[index] = newEntityConfig;
let config = this._config!;
config = { ...config, entities: newEntities };
// remove inappropriate stat options dependently on entities
config = await this._cleanConfig(config);
// only a different statistic can invalidate the stat options
if (newEntityConfig.entity !== oldEntityId) {
config = await this._cleanConfig(config);
}
// normalize a generated yaml code
config = this._orderProperties(config);
this._config = config;
@@ -1,3 +1,4 @@
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import {
getCustomBadgeEntry,
getCustomCardEntry,
@@ -38,7 +39,9 @@ export const getCardDocumentationURL = (
type: string
): string | undefined => {
if (isCustomType(type)) {
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
return sanitizeHttpUrl(
getCustomCardEntry(stripCustomPrefix(type))?.documentationURL
);
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_CARD_URLS[type] || type}`;
@@ -49,7 +52,9 @@ export const getBadgeDocumentationURL = (
type: string
): string | undefined => {
if (isCustomType(type)) {
return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL;
return sanitizeHttpUrl(
getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL
);
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_BADGE_URLS[type] || "badges"}`;
@@ -12,7 +12,10 @@ class EntityRowDirective extends Directive {
private _name?: string;
private _lastHass?: HomeAssistant;
render(entityId: string, name: string, hass: HomeAssistant) {
this._lastHass = hass;
if (
!this._element ||
(this._entityId !== entityId &&
@@ -22,6 +25,15 @@ class EntityRowDirective extends Directive {
entity: entityId,
name,
} as LovelaceRowConfig);
this._element.addEventListener(
"ll-upgrade",
() => {
if ("hass" in this._element!) {
this._element!.hass = this._lastHass;
}
},
{ once: true }
);
} else if (this._entityId !== entityId || this._name !== name) {
(this._element as LovelaceRow).setConfig?.({
entity: entityId,
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -16,6 +17,11 @@ class HuiWeblinkRow extends LitElement implements LovelaceRow {
throw new Error("URL required");
}
// Reject schemes that would run script in the frontend's origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = {
icon: "mdi:link",
name: config.url,
@@ -568,10 +568,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
...(sidebarSection && {
sidebar: {
sections: [sidebarSection],
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
sidebar_label: hass.localize(
"ui.panel.lovelace.strategy.home.summaries"
),
visibility: [LARGE_SCREEN_CONDITION],
},
}),
+37 -19
View File
@@ -87,11 +87,13 @@ 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(containerStyle.paddingLeft);
const paddingRight = parsePx(containerStyle.paddingRight);
const paddingLeft = parsePx(wrapperStyle.paddingLeft);
const paddingRight = parsePx(wrapperStyle.paddingRight);
const padding = paddingLeft + paddingRight;
const minColumnWidth = parsePx(
style.getPropertyValue("--column-min-width")
@@ -176,6 +178,16 @@ 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 &&
hasSidebar &&
this._config?.sidebar?.content_label &&
this._config?.sidebar?.sidebar_label
);
const totalSectionCount =
this._sectionColumnCount + (editMode ? 1 : 0) + (hasSidebar ? 1 : 0);
@@ -184,9 +196,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
Math.min(this._maxColumns, totalSectionCount),
1
);
// On mobile with sidebar, use full width for whichever view is active
const contentColumnCount =
hasSidebar && !this.narrow ? Math.max(1, columnCount - 1) : columnCount;
const contentColumnCount = hasSidebar
? Math.max(1, columnCount - 1)
: columnCount;
const sectionNeedsMargin = computeSectionsBackgroundAlignment(
sections,
@@ -198,7 +211,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
class="wrapper ${classMap({
"top-margin": Boolean(this._config?.top_margin),
"has-sidebar": Boolean(hasSidebar),
narrow: this.narrow,
"single-column": singleColumn,
"has-sidebar-tabs": useSidebarTabs,
})}"
style=${styleMap({
"--column-count": columnCount,
@@ -213,9 +227,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.config=${this._config?.header}
></hui-view-header>
${
this.narrow && hasSidebar
useSidebarTabs
? html`
<div class="mobile-tabs">
<div class="sidebar-tabs">
<ha-control-select
.value=${this._sidebarTabActive ? "sidebar" : "content"}
@value-changed=${this._viewChanged}
@@ -246,7 +260,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<div
class="content ${classMap({
dense: Boolean(this._config?.dense_section_placement),
"mobile-hidden": this.narrow && this._sidebarTabActive,
hidden: useSidebarTabs && this._sidebarTabActive,
})}"
>
${repeat(
@@ -333,8 +347,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
? html`
<hui-view-sidebar
class=${classMap({
"mobile-hidden":
!hasSidebar || (this.narrow && !this._sidebarTabActive),
hidden:
!hasSidebar ||
(useSidebarTabs && !this._sidebarTabActive),
})}
.hass=${this.hass}
.badges=${this.badges}
@@ -602,8 +617,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
[sidebar-start] 1fr;
}
/* On mobile with sidebar, content and sidebar both take full width */
.wrapper.narrow.has-sidebar .container {
/* With a single column, content and sidebar stack at full width */
.wrapper.single-column.has-sidebar .container {
grid-template-columns: 1fr;
}
@@ -611,18 +626,21 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
grid-column: sidebar-start / -1;
}
.wrapper.narrow hui-view-sidebar {
.wrapper.single-column hui-view-sidebar {
grid-column: 1 / -1;
}
.wrapper.has-sidebar-tabs hui-view-sidebar {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
}
.mobile-hidden {
.hidden {
display: none !important;
}
.mobile-tabs {
.sidebar-tabs {
position: fixed;
bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom));
left: 50%;
@@ -631,7 +649,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
z-index: 1;
}
.mobile-tabs ha-control-select {
.sidebar-tabs ha-control-select {
width: max-content;
min-width: 280px;
max-width: 90%;
@@ -659,11 +677,11 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
gap: var(--row-gap) var(--column-gap);
}
.wrapper.narrow .content {
.wrapper.single-column .content {
grid-column: 1 / -1;
}
.wrapper.narrow.has-sidebar .content {
.wrapper.has-sidebar-tabs .content {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
+1 -7
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
@@ -148,12 +148,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
this._migrating = false;
}
}
static styles = css`
a {
color: var(--primary-color);
}
`;
}
declare global {
+9 -1
View File
@@ -13,6 +13,9 @@ import { showToast } from "../util/toast";
import type { HassBaseEl } from "./hass-base-mixin";
import { navigate } from "../common/navigate";
const CONNECTION_LOST_TOAST_ID = "connection-lost";
const SERVER_STARTUP_TOAST_ID = "server-startup";
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
private _subscribedBootstrapIntegrations?: Promise<UnsubscribeFunc>;
@@ -34,6 +37,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (oldHass?.config?.state !== this.hass!.config.state) {
if (this.hass!.config.state === STATE_NOT_RUNNING) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.starting") ||
"Home Assistant is starting. Not everything will be available until it is finished.",
@@ -57,6 +61,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
) {
this._unsubscribeBootstrapIntegrations();
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.started"),
duration: 5000,
});
@@ -95,6 +100,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
return;
}
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: "",
duration: 0,
});
@@ -106,6 +112,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._disconnectedTimeout = window.setTimeout(() => {
this._disconnectedTimeout = undefined;
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.connection_lost"),
duration: -1,
dismissable: false,
@@ -120,6 +127,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (Object.keys(message).length === 0) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.wrapping_up_startup") ||
`Wrapping up startup. Not everything will be available until it is finished.`,
@@ -142,7 +150,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)[0][0];
showToast(this, {
id: "integration_starting",
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.integration_starting", {
integration: domainToName(this.hass!.localize, integration),
+3
View File
@@ -8,6 +8,7 @@ import type { HASSDomEvent } from "../common/dom/fire_event";
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { updateLaunchScreenLogo } from "../util/launch-screen";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -145,6 +146,8 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
true
);
updateLaunchScreenLogo(darkMode);
if (darkMode !== this.hass.themes.darkMode) {
this._updateHass({
themes: { ...this.hass.themes!, darkMode },
+30 -8
View File
@@ -75,7 +75,7 @@
},
"ui": {
"init": {
"loading": "Loading data",
"loading": "Loading...",
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
"project_from": "A project from the",
"error": {
@@ -2396,7 +2396,7 @@
"header": "Copy pairing code",
"step_1": "[%key:ui::dialogs::matter-add-device::google_home::step_1%]",
"step_2": "[%key:ui::dialogs::matter-add-device::google_home::step_2%]",
"step_3": "Tap {link_apps_services} and choose {use_pairing_code} form the list",
"step_3": "Tap {link_apps_services} and choose {use_pairing_code} from the list",
"linked_matter_apps_services": "[%key:ui::dialogs::matter-add-device::google_home::linked_matter_apps_services%]",
"link_apps_services": "[%key:ui::dialogs::matter-add-device::google_home::link_apps_services%]",
"use_pairing_code": "Use Pairing Code",
@@ -3851,7 +3851,7 @@
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser dev tools."
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
@@ -3896,7 +3896,7 @@
},
"actions": {
"title": "Actions",
"description": "The actions dev tool allows you to perform any action available in Home Assistant.",
"description": "The actions tool allows you to perform any action available in Home Assistant.",
"call_service": "Perform action",
"response": "Response",
"column_parameter": "Parameter",
@@ -3955,6 +3955,9 @@
"about": "About templates",
"editor": "Template editor",
"result": "Result",
"result_placeholder": "Your template result will appear here.",
"layout_stacked": "Drag to resize, click for stacked view",
"layout_side_by_side": "Drag to resize, click for side-by-side view",
"reset": "Reset to demo template",
"confirm_reset": "Do you want to reset your current template back to the demo template?",
"confirm_clear": "Do you want to clear your current template?",
@@ -4476,7 +4479,8 @@
"internal_url_https_error_title": "Invalid local network URL",
"internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certificate.",
"internal_url_automatic_description": "Use the configured network settings",
"internal_url_placeholder": "http://<some IP address>:8123",
"internal_url_placeholder": "{protocol}://192.168.1.100{port}",
"external_url_placeholder": "{protocol}://example.duckdns.org{port}",
"invalid_url": "Invalid URL"
},
"hardware": {
@@ -7550,6 +7554,9 @@
"download_backup": "Download backup",
"download_backup_description": "Save your Zigbee network configuration to a file",
"download_backup_action": "Download",
"backup_failed": "Failed to create backup",
"backup_incomplete_title": "Backup is incomplete",
"backup_incomplete_text": "A backup has been created but it is incomplete and cannot be restored. This is a limitation of your adapter's firmware.",
"migrate_radio": "Migrate adapter",
"migrate_radio_description": "Move your Zigbee network to a different adapter",
"migrate_radio_action": "Migrate",
@@ -8648,7 +8655,14 @@
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
"learn_more": "Learn more",
"preview": "Preview",
"preview_error": "Failed to generate preview",
"reset": "Reset to default",
"examples": {
"area": "Living room",
"device": "Thermostat",
"entity": "Temperature",
"floor": "Ground floor"
},
"editor": {
"label": "Format",
"add": "Add",
@@ -8748,8 +8762,11 @@
"save_no_changes": "Nothing changed — no restart needed.",
"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.",
"invalid_host": "Enter a valid IP address.",
"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_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}",
@@ -8759,6 +8776,11 @@
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
"confirm": "Save and restart"
},
"restart_address": {
"title": "Home Assistant is restarting",
"text": "The address changed. Once Home Assistant has restarted, you can reach it at:",
"note": "Depending on your network setup, this address may not be reachable from this device."
},
"ssl_profile_modern": "Modern",
"ssl_profile_intermediate": "Intermediate",
"sections": {
@@ -8782,8 +8804,8 @@
"login_attempts_threshold": "Login attempts before ban"
},
"helpers": {
"server_port": "The port Home Assistant listens on. Default is 8123.",
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
"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.",
"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.",
@@ -0,0 +1,40 @@
import type { Connection } from "home-assistant-js-websocket";
/** Duck-typed shape of a `getCollection()` result cached on a `Connection`. */
interface CachedCollection {
refresh: () => Promise<unknown>;
subscribe: (subscriber: (state: unknown) => void) => () => void;
}
const isCachedCollection = (value: unknown): value is CachedCollection =>
typeof value === "object" &&
value !== null &&
typeof (value as CachedCollection).subscribe === "function" &&
typeof (value as CachedCollection).refresh === "function";
/**
* Drop the websocket collections that were created by this window from the
* shared connection cache.
*
* `getCollection()` caches collections (entity registry, label registry, ...) on
* the `Connection` object. A custom panel embedded in an iframe shares the
* connection with the main window, so a collection the panel is the first to
* request is created inside the iframe's realm. When the iframe is removed, that
* realm is destroyed while the collection stays cached on the connection - its
* store, and the timer that hands the cached state to new subscribers, are gone.
* Every later subscriber (the main frontend or the next instance of the panel)
* then waits forever for a callback that can never fire.
*
* Must be called from the realm that is going away - `instanceof Function` is
* realm-specific and only matches collections created by this window.
*/
export const dropRealmCollections = (connection: Connection): void => {
// `getCollection()` stores collections on the connection under keys that are
// not part of its type, so treat it as the bag of properties it is.
const cache = connection as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(cache)) {
if (isCachedCollection(value) && value.subscribe instanceof Function) {
delete cache[key];
}
}
};
+24 -18
View File
@@ -1,12 +1,11 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
import { withViewTransition } from "../common/util/view-transition";
let removalInitiated = false;
/**
* Removes the launch screen with a fade-out view transition.
* Removes the launch screen with a CSS fade-out transition.
*
* @param instant - Removes the launch screen without animation. Used when the
* external app covers the frontend with its own splash screen until the
@@ -26,23 +25,16 @@ export const removeLaunchScreen = (instant = false): boolean => {
return true;
}
withViewTransition((viewTransitionAvailable) => {
if (viewTransitionAvailable) {
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
return;
}
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
},
parseAnimationDuration(durationFromCss || "250ms")
);
});
},
parseAnimationDuration(durationFromCss || "250ms")
);
return true;
};
@@ -57,6 +49,20 @@ export const renderLaunchScreenContent = (
updateLaunchScreenAttribution(attribution);
};
/**
* Switches the launch screen OHF logo to the variant matching the applied
* theme. The `<picture>` element initially picks a variant based on the system
* color scheme, which can differ from the theme the frontend ends up applying.
*/
export const updateLaunchScreenLogo = (darkMode: boolean) => {
const logoSourceElement = document.querySelector<HTMLSourceElement>(
"#ha-launch-screen .ohf-logo source"
);
if (logoSourceElement) {
logoSourceElement.media = darkMode ? "all" : "not all";
}
};
export const updateLaunchScreenAttribution = (attribution: string) => {
const attributionElement = document.getElementById(
"ha-launch-screen-attribution"
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -263,4 +264,43 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});

Some files were not shown because too many files have changed in this diff Show More