Merge branch 'rc'

This commit is contained in:
Bram Kragten 2025-01-03 15:52:04 +01:00
commit 7aa2136c21
67 changed files with 867 additions and 285 deletions

View File

@ -3,6 +3,7 @@
import { constants } from "node:zlib"; import { constants } from "node:zlib";
import gulp from "gulp"; import gulp from "gulp";
import brotli from "gulp-brotli"; import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import paths from "../paths.cjs"; import paths from "../paths.cjs";
const filesGlob = "*.{js,json,css,svg,xml}"; const filesGlob = "*.{js,json,css,svg,xml}";
@ -12,17 +13,18 @@ const brotliOptions = {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
}, },
}; };
const zopfliOptions = { threshold: 150 };
const compressModern = (rootDir, modernDir) => const compressModern = (rootDir, modernDir, compress) =>
gulp gulp
.src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], { .src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], {
base: rootDir, base: rootDir,
allowEmpty: true, allowEmpty: true,
}) })
.pipe(brotli(brotliOptions)) .pipe(compress === "zopfli" ? zopfli(zopfliOptions) : brotli(brotliOptions))
.pipe(gulp.dest(rootDir)); .pipe(gulp.dest(rootDir));
const compressOther = (rootDir, modernDir) => const compressOther = (rootDir, modernDir, compress) =>
gulp gulp
.src( .src(
[ [
@ -33,21 +35,52 @@ const compressOther = (rootDir, modernDir) =>
], ],
{ base: rootDir, allowEmpty: true } { base: rootDir, allowEmpty: true }
) )
.pipe(brotli(brotliOptions)) .pipe(compress === "zopfli" ? zopfli(zopfliOptions) : brotli(brotliOptions))
.pipe(gulp.dest(rootDir)); .pipe(gulp.dest(rootDir));
const compressAppModern = () => const compressAppModernBrotli = () =>
compressModern(paths.app_output_root, paths.app_output_latest); compressModern(paths.app_output_root, paths.app_output_latest, "brotli");
const compressHassioModern = () => const compressAppModernZopfli = () =>
compressModern(paths.hassio_output_root, paths.hassio_output_latest); compressModern(paths.app_output_root, paths.app_output_latest, "zopfli");
const compressAppOther = () => const compressHassioModernBrotli = () =>
compressOther(paths.app_output_root, paths.app_output_latest); compressModern(
const compressHassioOther = () => paths.hassio_output_root,
compressOther(paths.hassio_output_root, paths.hassio_output_latest); paths.hassio_output_latest,
"brotli"
);
const compressHassioModernZopfli = () =>
compressModern(
paths.hassio_output_root,
paths.hassio_output_latest,
"zopfli"
);
gulp.task("compress-app", gulp.parallel(compressAppModern, compressAppOther)); const compressAppOtherBrotli = () =>
compressOther(paths.app_output_root, paths.app_output_latest, "brotli");
const compressAppOtherZopfli = () =>
compressOther(paths.app_output_root, paths.app_output_latest, "zopfli");
const compressHassioOtherBrotli = () =>
compressOther(paths.hassio_output_root, paths.hassio_output_latest, "brotli");
const compressHassioOtherZopfli = () =>
compressOther(paths.hassio_output_root, paths.hassio_output_latest, "zopfli");
gulp.task(
"compress-app",
gulp.parallel(
compressAppModernBrotli,
compressAppOtherBrotli,
compressAppModernZopfli,
compressAppOtherZopfli
)
);
gulp.task( gulp.task(
"compress-hassio", "compress-hassio",
gulp.parallel(compressHassioModern, compressHassioOther) gulp.parallel(
compressHassioModernBrotli,
compressHassioOtherBrotli,
compressHassioModernZopfli,
compressHassioOtherZopfli
)
); );

View File

@ -179,8 +179,8 @@ class HassioBackupDialog
} }
private async _restoreClicked() { private async _restoreClicked() {
this._restoringBackup = true;
const backupDetails = this._backupContent.backupDetails(); const backupDetails = this._backupContent.backupDetails();
this._restoringBackup = true;
const supervisor = this._dialogParams?.supervisor; const supervisor = this._dialogParams?.supervisor;
if (supervisor !== undefined && supervisor.info.state !== "running") { if (supervisor !== undefined && supervisor.info.state !== "running") {
@ -196,12 +196,12 @@ class HassioBackupDialog
if ( if (
!(await showConfirmationDialog(this, { !(await showConfirmationDialog(this, {
title: this._localize( title: this._localize(
this._backupContent.backupType === "full" this._backup!.type === "full"
? "confirm_restore_full_backup_title" ? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title" : "confirm_restore_partial_backup_title"
), ),
text: this._localize( text: this._localize(
this._backupContent.backupType === "full" this._backup!.type === "full"
? "confirm_restore_full_backup_text" ? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text" : "confirm_restore_partial_backup_text"
), ),
@ -216,9 +216,9 @@ class HassioBackupDialog
try { try {
await restoreBackup( await restoreBackup(
this.hass, this.hass,
this._backupContent.backupType, this._backup!.type,
this._backup!.slug, this._backup!.slug,
backupDetails, { ...backupDetails, background: this._dialogParams?.onboarding },
!!this.hass && atLeastVersion(this.hass.config.version, 2021, 9) !!this.hass && atLeastVersion(this.hass.config.version, 2021, 9)
); );

View File

@ -115,6 +115,7 @@
"element-internals-polyfill": "1.3.12", "element-internals-polyfill": "1.3.12",
"fuse.js": "7.0.0", "fuse.js": "7.0.0",
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", "hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.4.0", "home-assistant-js-websocket": "9.4.0",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20241224.0" version = "20250103.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@ -1,14 +1,19 @@
type NonUndefined<T> = T extends undefined ? never : T; type NonNullUndefined<T> = T extends undefined
? never
: T extends null
? never
: T;
/** /**
* Ensure that the input is an array or wrap it in an array * Ensure that the input is an array or wrap it in an array
* @param value - The value to ensure is an array * @param value - The value to ensure is an array
*/ */
export function ensureArray(value: undefined): undefined; export function ensureArray(value: undefined): undefined;
export function ensureArray<T>(value: T | T[]): NonUndefined<T>[]; export function ensureArray(value: null): null;
export function ensureArray<T>(value: T | readonly T[]): NonUndefined<T>[]; export function ensureArray<T>(value: T | T[]): NonNullUndefined<T>[];
export function ensureArray<T>(value: T | readonly T[]): NonNullUndefined<T>[];
export function ensureArray(value) { export function ensureArray(value) {
if (value === undefined || Array.isArray(value)) { if (value === undefined || value === null || Array.isArray(value)) {
return value; return value;
} }
return [value]; return [value];

View File

@ -14,9 +14,16 @@ export interface NavigateOptions {
data?: any; data?: any;
} }
export const navigate = async (path: string, options?: NavigateOptions) => { // max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
export const navigate = async (
path: string,
options?: NavigateOptions,
timestamp = Date.now()
) => {
const { history } = mainWindow; const { history } = mainWindow;
if (history.state?.dialog) { if (history.state?.dialog && Date.now() - timestamp < DIALOG_WAIT_TIMEOUT) {
const closed = await closeAllDialogs(); const closed = await closeAllDialogs();
if (!closed) { if (!closed) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -26,7 +33,7 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
// need to wait for history state to be updated in case a dialog was closed // need to wait for history state to be updated in case a dialog was closed
setTimeout(() => { setTimeout(() => {
navigate(path, options).then(resolve); navigate(path, options, timestamp).then(resolve);
}); });
}); });
} }

View File

@ -1,4 +1,4 @@
export const copyToClipboard = async (str) => { export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
if (navigator.clipboard) { if (navigator.clipboard) {
try { try {
await navigator.clipboard.writeText(str); await navigator.clipboard.writeText(str);
@ -8,10 +8,12 @@ export const copyToClipboard = async (str) => {
} }
} }
const root = rootEl ?? document.body;
const el = document.createElement("textarea"); const el = document.createElement("textarea");
el.value = str; el.value = str;
document.body.appendChild(el); root.appendChild(el);
el.select(); el.select();
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(el); root.removeChild(el);
}; };

View File

@ -1,7 +1,25 @@
class TimeoutError extends Error {
public timeout: number;
constructor(timeout: number, ...params) {
super(...params);
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, TimeoutError);
}
this.name = "TimeoutError";
// Custom debugging information
this.timeout = timeout;
this.message = `Timed out in ${timeout} ms.`;
}
}
export const promiseTimeout = (ms: number, promise: Promise<any> | any) => { export const promiseTimeout = (ms: number, promise: Promise<any> | any) => {
const timeout = new Promise((_resolve, reject) => { const timeout = new Promise((_resolve, reject) => {
setTimeout(() => { setTimeout(() => {
reject(`Timed out in ${ms} ms.`); reject(new TimeoutError(ms));
}, ms); }, ms);
}); });

View File

@ -10,11 +10,13 @@ import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { clamp } from "../../common/number/clamp"; import { clamp } from "../../common/number/clamp";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import { isMac } from "../../util/is_mac"; import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@ -300,6 +302,16 @@ export class HaChartBase extends LitElement {
: this.hass.localize("ui.components.history_charts.zoom_hint")} : this.hass.localize("ui.components.history_charts.zoom_hint")}
</div> </div>
</div> </div>
${this._isZoomed && this.chartType !== "timeline"
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
${this._tooltip ${this._tooltip
? html`<div ? html`<div
class="chart-tooltip ${classMap({ class="chart-tooltip ${classMap({
@ -420,7 +432,11 @@ export class HaChartBase extends LitElement {
modifierKey, modifierKey,
speed: 0.05, speed: 0.05,
}, },
mode: "x", mode:
this.chartType !== "timeline" &&
(this.options?.scales?.y as any)?.type === "category"
? "y"
: "x",
onZoomComplete: () => { onZoomComplete: () => {
const isZoomed = this.chart?.isZoomedOrPanned() ?? false; const isZoomed = this.chart?.isZoomedOrPanned() ?? false;
if (this._isZoomed && !isZoomed) { if (this._isZoomed && !isZoomed) {
@ -541,6 +557,10 @@ export class HaChartBase extends LitElement {
} }
} }
private _handleZoomReset() {
this.chart?.resetZoom();
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {
@ -552,6 +572,9 @@ export class HaChartBase extends LitElement {
height: 0; height: 0;
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1); transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
} }
.chart-container {
position: relative;
}
canvas { canvas {
max-height: var(--chart-max-height, 400px); max-height: var(--chart-max-height, 400px);
} }
@ -670,6 +693,16 @@ export class HaChartBase extends LitElement {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3); box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3);
} }
.zoom-reset {
position: absolute;
top: 16px;
right: 4px;
background: var(--card-background-color);
border-radius: 4px;
--mdc-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
`; `;
} }
} }

View File

@ -515,7 +515,7 @@ export class HaDataTable extends LitElement {
return html`<div class="mdc-data-table__row">${row.content}</div>`; return html`<div class="mdc-data-table__row">${row.content}</div>`;
} }
if (row.empty) { if (row.empty) {
return html`<div class="mdc-data-table__row"></div>`; return html`<div class="mdc-data-table__row empty-row"></div>`;
} }
return html` return html`
<div <div
@ -960,6 +960,13 @@ export class HaDataTable extends LitElement {
width: var(--table-row-width, 100%); width: var(--table-row-width, 100%);
} }
.mdc-data-table__row.empty-row {
height: var(
--data-table-empty-row-height,
var(--data-table-row-height, 52px)
);
}
.mdc-data-table__row ~ .mdc-data-table__row { .mdc-data-table__row ~ .mdc-data-table__row {
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
} }

View File

@ -222,7 +222,9 @@ export class HaDevicePicker extends LitElement {
return { return {
id: device.id, id: device.id,
name: name, name:
name ||
this.hass.localize("ui.components.device-picker.unnamed_device"),
area: area:
device.area_id && areas[device.area_id] device.area_id && areas[device.area_id]
? areas[device.area_id].name ? areas[device.area_id].name

View File

@ -102,10 +102,10 @@ export class HaDialog extends DialogBase {
align-items: var(--vertical-align-dialog, center); align-items: var(--vertical-align-dialog, center);
} }
.mdc-dialog__title { .mdc-dialog__title {
padding: 12px 12px 0; padding: 24px 24px 0 24px;
} }
.mdc-dialog--scrollable .mdc-dialog__title { .mdc-dialog__title:has(span) {
padding: 12px; padding: 12px 12px 0;
} }
.mdc-dialog__actions { .mdc-dialog__actions {
padding: 12px 24px 12px 24px; padding: 12px 24px 12px 24px;

View File

@ -62,6 +62,7 @@ export class HaGauge extends LitElement {
if ( if (
!this._updated || !this._updated ||
(!changedProperties.has("value") && (!changedProperties.has("value") &&
!changedProperties.has("valueText") &&
!changedProperties.has("label") && !changedProperties.has("label") &&
!changedProperties.has("_segment_label")) !changedProperties.has("_segment_label"))
) { ) {

View File

@ -95,7 +95,7 @@ export class HaPictureUpload extends LitElement {
<ha-button <ha-button
@click=${this._handleChangeClick} @click=${this._handleChangeClick}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.components.picture-upload.change_picture" "ui.components.picture-upload.clear_picture"
)} )}
> >
</ha-button> </ha-button>

View File

@ -89,7 +89,7 @@ export class HaServiceControl extends LitElement {
@property({ attribute: "show-advanced", type: Boolean }) public showAdvanced = @property({ attribute: "show-advanced", type: Boolean }) public showAdvanced =
false; false;
@property({ attribute: false, type: Boolean, reflect: true }) @property({ attribute: "hide-picker", type: Boolean, reflect: true })
public hidePicker = false; public hidePicker = false;
@property({ attribute: "hide-description", type: Boolean }) @property({ attribute: "hide-description", type: Boolean })

View File

@ -117,7 +117,7 @@ class DialogMediaManage extends LitElement {
: html` : html`
<ha-button <ha-button
class="danger" class="danger"
slot="title" slot="navigationIcon"
.disabled=${this._deleting} .disabled=${this._deleting}
.label=${this.hass.localize( .label=${this.hass.localize(
`ui.components.media-browser.file_management.${ `ui.components.media-browser.file_management.${
@ -212,8 +212,8 @@ class DialogMediaManage extends LitElement {
> >
${this.hass.localize( ${this.hass.localize(
"ui.components.media-browser.file_management.tip_storage_panel" "ui.components.media-browser.file_management.tip_storage_panel"
)} )}</a
</a>`, >`,
} }
)} )}
</ha-tip>` </ha-tip>`

View File

@ -20,8 +20,8 @@ export class HatGraphNode extends LitElement {
@property({ attribute: false, reflect: true, type: Boolean }) notEnabled = @property({ attribute: false, reflect: true, type: Boolean }) notEnabled =
false; false;
@property({ attribute: false, reflect: true, type: Boolean }) graphStart = @property({ attribute: "graph-start", reflect: true, type: Boolean })
false; graphStart = false;
@property({ type: Boolean, attribute: "nofocus" }) noFocus = false; @property({ type: Boolean, attribute: "nofocus" }) noFocus = false;
@ -112,7 +112,7 @@ export class HatGraphNode extends LitElement {
var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px
); );
} }
:host([graphStart]) { :host([graph-start]) {
height: calc(var(--hat-graph-node-size) + 2px); height: calc(var(--hat-graph-node-size) + 2px);
} }
:host([track]) { :host([track]) {

View File

@ -91,7 +91,7 @@ export class HatScriptGraph extends LitElement {
} }
return html` return html`
<hat-graph-node <hat-graph-node
graphStart graph-start
?track=${track} ?track=${track}
@focus=${this._selectNode(config, path)} @focus=${this._selectNode(config, path)}
?active=${this.selected === path} ?active=${this.selected === path}
@ -354,8 +354,8 @@ export class HatScriptGraph extends LitElement {
></hat-graph-node> ></hat-graph-node>
<div <div
style=${`width: ${NODE_SIZE + SPACING}px;`} style=${`width: ${NODE_SIZE + SPACING}px;`}
graphStart graph-start
graphEnd graph-end
></div> ></div>
<div ?track=${trackPass}></div> <div ?track=${trackPass}></div>
<hat-graph-node <hat-graph-node

View File

@ -737,18 +737,22 @@ const tryDescribeTrigger = (
? computeStateName(hass.states[trigger.entity_id]) ? computeStateName(hass.states[trigger.entity_id])
: trigger.entity_id; : trigger.entity_id;
let offsetChoice = trigger.offset.startsWith("-") ? "before" : "after"; let offsetChoice: string = "other";
let offset: string | string[] = trigger.offset.startsWith("-") let offset: string | string[] = "";
? trigger.offset.substring(1).split(":") if (trigger.offset) {
: trigger.offset.split(":"); offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
const duration = { offset = trigger.offset.startsWith("-")
hours: offset.length > 0 ? +offset[0] : 0, ? trigger.offset.substring(1).split(":")
minutes: offset.length > 1 ? +offset[1] : 0, : trigger.offset.split(":");
seconds: offset.length > 2 ? +offset[2] : 0, const duration = {
}; hours: offset.length > 0 ? +offset[0] : 0,
offset = formatDurationLong(hass.locale, duration); minutes: offset.length > 1 ? +offset[1] : 0,
if (offset === "") { seconds: offset.length > 2 ? +offset[2] : 0,
offsetChoice = "other"; };
offset = formatDurationLong(hass.locale, duration);
if (offset === "") {
offsetChoice = "other";
}
} }
return hass.localize( return hass.localize(

View File

@ -46,6 +46,7 @@ export interface HassioFullBackupCreateParams {
name: string; name: string;
password?: string; password?: string;
confirm_password?: string; confirm_password?: string;
background?: boolean;
} }
export interface HassioPartialBackupCreateParams export interface HassioPartialBackupCreateParams
extends HassioFullBackupCreateParams { extends HassioFullBackupCreateParams {

View File

@ -9,7 +9,7 @@ export interface ShowViewConfig {
export interface LovelaceViewBackgroundConfig { export interface LovelaceViewBackgroundConfig {
image?: string; image?: string;
transparency?: number; opacity?: number;
size?: "auto" | "cover" | "contain"; size?: "auto" | "cover" | "contain";
alignment?: alignment?:
| "top left" | "top left"

View File

@ -3,7 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog"; import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-formfield"; import "../../components/ha-formfield";
import "../../components/ha-switch"; import "../../components/ha-switch";
import type { HaSwitch } from "../../components/ha-switch"; import type { HaSwitch } from "../../components/ha-switch";
@ -52,14 +52,14 @@ class DialogConfigEntrySystemOptions extends LitElement {
<ha-dialog <ha-dialog
open open
@closed=${this.closeDialog} @closed=${this.closeDialog}
.heading=${this.hass.localize( .heading=${createCloseHeading(
"ui.dialogs.config_entry_system_options.title", this.hass,
{ this.hass.localize("ui.dialogs.config_entry_system_options.title", {
integration: integration:
this.hass.localize( this.hass.localize(
`component.${this._params.entry.domain}.title` `component.${this._params.entry.domain}.title`
) || this._params.entry.domain, ) || this._params.entry.domain,
} })
)} )}
> >
${this._error ? html` <div class="error">${this._error}</div> ` : ""} ${this._error ? html` <div class="error">${this._error}</div> ` : ""}

View File

@ -225,6 +225,7 @@ export const makeDialogManager = (
}; };
const _handleClosedFocus = async (ev: HASSDomEvent<DialogClosedParams>) => { const _handleClosedFocus = async (ev: HASSDomEvent<DialogClosedParams>) => {
if (!LOADED[ev.detail.dialog]) return;
const closedFocusTargets = LOADED[ev.detail.dialog].closedFocusTargets; const closedFocusTargets = LOADED[ev.detail.dialog].closedFocusTargets;
delete LOADED[ev.detail.dialog].closedFocusTargets; delete LOADED[ev.detail.dialog].closedFocusTargets;
if (!closedFocusTargets) return; if (!closedFocusTargets) return;

View File

@ -99,6 +99,7 @@ class MoreInfoScript extends LitElement {
${this.hass.localize("ui.card.script.run_script")} ${this.hass.localize("ui.card.script.run_script")}
</div> </div>
<ha-service-control <ha-service-control
hide-picker
hide-description hide-description
.hass=${this.hass} .hass=${this.hass}
.value=${this._scriptData} .value=${this._scriptData}

View File

@ -530,7 +530,9 @@ export class QuickBar extends LitElement {
? this.hass.areas[device.area_id] ? this.hass.areas[device.area_id]
: undefined; : undefined;
const deviceItem = { const deviceItem = {
primaryText: computeDeviceName(device, this.hass), primaryText:
computeDeviceName(device, this.hass) ||
this.hass.localize("ui.components.device-picker.unnamed_device"),
deviceId: device.id, deviceId: device.id,
area: area?.name, area: area?.name,
action: () => navigate(`/config/devices/device/${device.id}`), action: () => navigate(`/config/devices/device/${device.id}`),

View File

@ -322,6 +322,13 @@ class HassTabsSubpage extends LitElement {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
:host([narrow]) .content {
height: calc(100% - var(--header-height));
height: calc(
100% - var(--header-height) - env(safe-area-inset-bottom)
);
}
:host([narrow]) .content.tabs { :host([narrow]) .content.tabs {
height: calc(100% - 2 * var(--header-height)); height: calc(100% - 2 * var(--header-height));
height: calc( height: calc(

View File

@ -614,9 +614,6 @@ export default class HaAutomationActionRow extends LitElement {
ha-icon-button { ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
} }
ha-card {
overflow: hidden;
}
.disabled { .disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
@ -649,6 +646,8 @@ export default class HaAutomationActionRow extends LitElement {
.disabled-bar { .disabled-bar {
background: var(--divider-color, #e0e0e0); background: var(--divider-color, #e0e0e0);
text-align: center; text-align: center;
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
} }
mwc-list-item[disabled] { mwc-list-item[disabled] {

View File

@ -328,8 +328,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
ha-icon-picker, ha-icon-picker,
ha-category-picker, ha-category-picker,
ha-labels-picker, ha-labels-picker,
ha-area-picker, ha-area-picker {
ha-chip-set {
display: block; display: block;
} }
ha-icon-picker, ha-icon-picker,

View File

@ -504,9 +504,6 @@ export default class HaAutomationConditionRow extends LitElement {
ha-button-menu { ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
} }
ha-card {
overflow: hidden;
}
.disabled { .disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
@ -539,6 +536,8 @@ export default class HaAutomationConditionRow extends LitElement {
.disabled-bar { .disabled-bar {
background: var(--divider-color, #e0e0e0); background: var(--divider-color, #e0e0e0);
text-align: center; text-align: center;
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
} }
ha-list-item[disabled] { ha-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color); --mdc-theme-text-primary-on-background: var(--disabled-text-color);
@ -560,6 +559,8 @@ export default class HaAutomationConditionRow extends LitElement {
overflow: hidden; overflow: hidden;
transition: max-height 0.3s; transition: max-height 0.3s;
text-align: center; text-align: center;
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
} }
.testing.active { .testing.active {
max-height: 100px; max-height: 100px;

View File

@ -27,6 +27,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl"; import { computeRTL } from "../../../common/util/compute_rtl";
import { afterNextRender } from "../../../common/util/render-status"; import { afterNextRender } from "../../../common/util/render-status";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
@ -944,8 +945,37 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
// wait for automation to appear in entity registry when creating a new automation // wait for automation to appear in entity registry when creating a new automation
if (entityRegPromise) { if (entityRegPromise) {
const automation = await entityRegPromise; try {
entityId = automation.entity_id; const automation = await promiseTimeout(2000, entityRegPromise);
entityId = automation.entity_id;
} catch (e) {
if (e instanceof Error && e.name === "TimeoutError") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.new_automation_setup_failed_title",
{
type: this.hass.localize(
"ui.panel.config.automation.editor.type_automation"
),
}
),
text: this.hass.localize(
"ui.panel.config.automation.editor.new_automation_setup_failed_text",
{
type: this.hass.localize(
"ui.panel.config.automation.editor.type_automation"
),
types: this.hass.localize(
"ui.panel.config.automation.editor.type_automation_plural"
),
}
),
warning: true,
});
} else {
throw e;
}
}
} }
if (entityId) { if (entityId) {
@ -965,9 +995,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
navigate(`/config/automation/edit/${id}`, { replace: true }); navigate(`/config/automation/edit/${id}`, { replace: true });
} }
} catch (errors: any) { } catch (errors: any) {
this._errors = errors.body.message || errors.error || errors.body; this._errors = errors.body?.message || errors.error || errors.body;
showToast(this, { showToast(this, {
message: errors.body.message || errors.error || errors.body, message: errors.body?.message || errors.error || errors.body,
}); });
throw errors; throw errors;
} finally { } finally {

View File

@ -651,9 +651,6 @@ export default class HaAutomationTriggerRow extends LitElement {
ha-button-menu { ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
} }
ha-card {
overflow: hidden;
}
.disabled { .disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
@ -686,6 +683,8 @@ export default class HaAutomationTriggerRow extends LitElement {
.disabled-bar { .disabled-bar {
background: var(--divider-color, #e0e0e0); background: var(--divider-color, #e0e0e0);
text-align: center; text-align: center;
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
} }
.triggered { .triggered {
cursor: pointer; cursor: pointer;
@ -702,6 +701,8 @@ export default class HaAutomationTriggerRow extends LitElement {
overflow: hidden; overflow: hidden;
transition: max-height 0.3s; transition: max-height 0.3s;
text-align: center; text-align: center;
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
} }
.triggered.active { .triggered.active {
max-height: 100px; max-height: 100px;

View File

@ -51,6 +51,9 @@ class HaBackupConfigAgents extends LitElement {
private _description(agentId: string) { private _description(agentId: string) {
if (agentId === CLOUD_AGENT) { if (agentId === CLOUD_AGENT) {
if (this.cloudStatus.logged_in && !this.cloudStatus.active_subscription) {
return "You currently do not have an active Home Assistant Cloud subscription.";
}
return "Note: It stores only one backup with a maximum size of 5 GB, regardless of your settings."; return "Note: It stores only one backup with a maximum size of 5 GB, regardless of your settings.";
} }
if (isNetworkMountAgent(agentId)) { if (isNetworkMountAgent(agentId)) {
@ -72,6 +75,10 @@ class HaBackupConfigAgents extends LitElement {
this._agentIds this._agentIds
); );
const description = this._description(agentId); const description = this._description(agentId);
const noCloudSubscription =
agentId === CLOUD_AGENT &&
this.cloudStatus.logged_in &&
!this.cloudStatus.active_subscription;
return html` return html`
<ha-md-list-item> <ha-md-list-item>
${isLocalAgent(agentId) ${isLocalAgent(agentId)
@ -107,7 +114,9 @@ class HaBackupConfigAgents extends LitElement {
<ha-switch <ha-switch
slot="end" slot="end"
id=${agentId} id=${agentId}
.checked=${this._value.includes(agentId)} .checked=${!noCloudSubscription &&
this._value.includes(agentId)}
.disabled=${noCloudSubscription}
@change=${this._agentToggled} @change=${this._agentToggled}
></ha-switch> ></ha-switch>
</ha-md-list-item> </ha-md-list-item>
@ -133,7 +142,11 @@ class HaBackupConfigAgents extends LitElement {
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in // Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)] this.value = [...new Set(this.value)]
.filter((agent) => this._agentIds.some((id) => id === agent)) .filter((agent) => this._agentIds.some((id) => id === agent))
.filter((id) => id !== CLOUD_AGENT || this.cloudStatus.logged_in); .filter(
(id) =>
id !== CLOUD_AGENT ||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
);
fireEvent(this, "value-changed", { value: this.value }); fireEvent(this, "value-changed", { value: this.value });
} }
@ -144,9 +157,6 @@ class HaBackupConfigAgents extends LitElement {
--md-list-item-leading-space: 0; --md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0; --md-list-item-trailing-space: 0;
} }
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-list-item img { ha-md-list-item img {
width: 48px; width: 48px;
} }

View File

@ -138,7 +138,8 @@ class HaBackupConfigData extends LitElement {
const include_addons = data.addons_mode === "custom" ? data.addons : []; const include_addons = data.addons_mode === "custom" ? data.addons : [];
this.value = { this.value = {
include_homeassistant: data.homeassistant || this.forceHomeAssistant, include_homeassistant:
data.homeassistant || data.database || this.forceHomeAssistant,
include_addons: include_addons.length ? include_addons : undefined, include_addons: include_addons.length ? include_addons : undefined,
include_all_addons: data.addons_mode === "all", include_all_addons: data.addons_mode === "all",
include_database: data.database, include_database: data.database,
@ -168,7 +169,7 @@ class HaBackupConfigData extends LitElement {
slot="end" slot="end"
@change=${this._switchChanged} @change=${this._switchChanged}
.checked=${data.homeassistant} .checked=${data.homeassistant}
.disabled=${this.forceHomeAssistant} .disabled=${this.forceHomeAssistant || data.database}
></ha-switch> ></ha-switch>
</ha-md-list-item> </ha-md-list-item>
@ -296,7 +297,6 @@ class HaBackupConfigData extends LitElement {
...data, ...data,
[target.id]: target.checked, [target.id]: target.checked,
}); });
fireEvent(this, "value-changed", { value: this.value });
} }
private _selectChanged(ev: Event) { private _selectChanged(ev: Event) {
@ -309,7 +309,6 @@ class HaBackupConfigData extends LitElement {
if (target.id === "addons_mode") { if (target.id === "addons_mode") {
this._showAddons = target.value === "custom"; this._showAddons = target.value === "custom";
} }
fireEvent(this, "value-changed", { value: this.value });
} }
private _addonsChanged(ev: CustomEvent) { private _addonsChanged(ev: CustomEvent) {
@ -320,7 +319,6 @@ class HaBackupConfigData extends LitElement {
...data, ...data,
addons, addons,
}); });
fireEvent(this, "value-changed", { value: this.value });
} }
static styles = css` static styles = css`
@ -332,9 +330,6 @@ class HaBackupConfigData extends LitElement {
ha-md-select { ha-md-select {
min-width: 210px; min-width: 210px;
} }
ha-md-list-item {
--md-item-overflow: visible;
}
@media all and (max-width: 450px) { @media all and (max-width: 450px) {
ha-md-select { ha-md-select {
min-width: 160px; min-width: 160px;

View File

@ -9,6 +9,7 @@ import { showChangeBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-c
import { showSetBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-set-backup-encryption-key"; import { showSetBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-set-backup-encryption-key";
import { downloadEmergencyKit } from "../../../../../data/backup"; import { downloadEmergencyKit } from "../../../../../data/backup";
import { showShowBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-show-backup-encryption-key";
@customElement("ha-backup-config-encryption-key") @customElement("ha-backup-config-encryption-key")
class HaBackupConfigEncryptionKey extends LitElement { class HaBackupConfigEncryptionKey extends LitElement {
@ -34,7 +35,13 @@ class HaBackupConfigEncryptionKey extends LitElement {
Download Download
</ha-button> </ha-button>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Show my encryption key</span>
<span slot="supporting-text">
Please keep your encryption key private.
</span>
<ha-button slot="end" @click=${this._show}>Show</ha-button>
</ha-md-list-item>
<ha-md-list-item> <ha-md-list-item>
<span slot="headline">Change encryption key</span> <span slot="headline">Change encryption key</span>
<span slot="supporting-text"> <span slot="supporting-text">
@ -68,6 +75,10 @@ class HaBackupConfigEncryptionKey extends LitElement {
downloadEmergencyKit(this.hass, this._value); downloadEmergencyKit(this.hass, this._value);
} }
private _show() {
showShowBackupEncryptionKeyDialog(this, { currentKey: this._value });
}
private _change() { private _change() {
showChangeBackupEncryptionKeyDialog(this, { showChangeBackupEncryptionKeyDialog(this, {
currentKey: this._value, currentKey: this._value,

View File

@ -323,9 +323,6 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select { ha-md-select {
min-width: 210px; min-width: 210px;
} }
ha-md-list-item {
--md-item-overflow: visible;
}
@media all and (max-width: 450px) { @media all and (max-width: 450px) {
ha-md-select { ha-md-select {
min-width: 160px; min-width: 160px;

View File

@ -37,10 +37,7 @@ class HaBackupOverviewBackups extends LitElement {
<p> <p>
Backups are essential for a reliable smart home. They help protect Backups are essential for a reliable smart home. They help protect
the work you've put into setting up your smart home, and if the the work you've put into setting up your smart home, and if the
worst happens, you can get back up and running quickly. It is worst happens, you can get back up and running quickly.
recommended that you create a backup every day. You should keep
three backups in at least two different locations, one of which
should be off-site.
</p> </p>
</div> </div>
<div class="card-actions"> <div class="card-actions">

View File

@ -34,7 +34,7 @@ class HaBackupBackupsSummary extends LitElement {
const { state: schedule } = config.schedule; const { state: schedule } = config.schedule;
if (schedule === BackupScheduleState.NEVER) { if (schedule === BackupScheduleState.NEVER) {
return "Automatic backups are disabled"; return "Automatic backups are not scheduled";
} }
let copiesText = "and keep all backups"; let copiesText = "and keep all backups";
@ -116,7 +116,7 @@ class HaBackupBackupsSummary extends LitElement {
return html` return html`
<ha-card class="my-backups"> <ha-card class="my-backups">
<div class="card-header">Automatic backups</div> <div class="card-header">Backup settings</div>
<div class="card-content"> <div class="card-content">
<ha-md-list> <ha-md-list>
<ha-md-list-item <ha-md-list-item
@ -128,7 +128,7 @@ class HaBackupBackupsSummary extends LitElement {
${this._scheduleDescription(this.config)} ${this._scheduleDescription(this.config)}
</div> </div>
<div slot="supporting-text"> <div slot="supporting-text">
Schedule and number of backups to keep Automatic backup schedule and retention
</div> </div>
<ha-icon-next slot="end"></ha-icon-next> <ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item> </ha-md-list-item>
@ -174,7 +174,7 @@ class HaBackupBackupsSummary extends LitElement {
</div> </div>
<div class="card-actions"> <div class="card-actions">
<ha-button @click=${this._configure}> <ha-button @click=${this._configure}>
Configure automatic backups Configure backup settings
</ha-button> </ha-button>
</div> </div>
</ha-card> </ha-card>

View File

@ -84,22 +84,22 @@ class HaBackupOverviewBackups extends LitElement {
const lastSuccessfulBackup = this._lastSuccessfulBackup(this.backups); const lastSuccessfulBackup = this._lastSuccessfulBackup(this.backups);
const lastSuccessfulBackupDate = lastSuccessfulBackup
? new Date(lastSuccessfulBackup.date)
: new Date(0);
const lastAttempt = this.config.last_attempted_automatic_backup const lastAttempt = this.config.last_attempted_automatic_backup
? new Date(this.config.last_attempted_automatic_backup) ? new Date(this.config.last_attempted_automatic_backup)
: undefined; : undefined;
const lastCompletedBackupDate = this.config.last_completed_automatic_backup
? new Date(this.config.last_completed_automatic_backup)
: undefined;
const now = new Date(); const now = new Date();
const lastBackupDescription = lastSuccessfulBackup const lastBackupDescription = lastSuccessfulBackup
? `Last successful backup ${relativeTime(lastSuccessfulBackupDate, this.hass.locale, now, true)} and stored to ${lastSuccessfulBackup.agent_ids?.length} locations.` ? `Last successful backup ${relativeTime(new Date(lastSuccessfulBackup.date), this.hass.locale, now, true)} and stored in ${lastSuccessfulBackup.agent_ids?.length} locations.`
: "You have no successful backups."; : "You have no successful backups.";
if (lastAttempt && lastAttempt > lastSuccessfulBackupDate) { if (lastAttempt && lastAttempt > (lastCompletedBackupDate || 0)) {
const lastAttemptDescription = `The last automatic backup trigged ${relativeTime(lastAttempt, this.hass.locale, now, true)} wasn't successful.`; const lastAttemptDescription = `The last automatic backup triggered ${relativeTime(lastAttempt, this.hass.locale, now, true)} wasn't successful.`;
return html` return html`
<ha-backup-summary-card <ha-backup-summary-card
heading="Last automatic backup failed" heading="Last automatic backup failed"
@ -119,6 +119,10 @@ class HaBackupOverviewBackups extends LitElement {
`; `;
} }
const nextBackupDescription = this._nextBackupDescription(
this.config.schedule.state
);
if (!lastSuccessfulBackup) { if (!lastSuccessfulBackup) {
return html` return html`
<ha-backup-summary-card <ha-backup-summary-card
@ -126,18 +130,20 @@ class HaBackupOverviewBackups extends LitElement {
description="You have no automatic backups yet." description="You have no automatic backups yet."
status="warning" status="warning"
> >
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
</ha-md-list>
</ha-backup-summary-card> </ha-backup-summary-card>
`; `;
} }
const nextBackupDescription = this._nextBackupDescription(
this.config.schedule.state
);
const numberOfDays = differenceInDays( const numberOfDays = differenceInDays(
// Subtract a few hours to avoid showing as overdue if it's just a few hours (e.g. daylight saving) // Subtract a few hours to avoid showing as overdue if it's just a few hours (e.g. daylight saving)
addHours(now, -OVERDUE_MARGIN_HOURS), addHours(now, -OVERDUE_MARGIN_HOURS),
lastSuccessfulBackupDate new Date(lastSuccessfulBackup.date)
); );
const isOverdue = const isOverdue =

View File

@ -90,7 +90,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
public showDialog(params: BackupOnboardingDialogParams): void { public showDialog(params: BackupOnboardingDialogParams): void {
this._params = params; this._params = params;
this._step = STEPS[0]; this._step = this._firstStep;
this._config = RECOMMENDED_CONFIG; this._config = RECOMMENDED_CONFIG;
const agents: string[] = []; const agents: string[] = [];
@ -129,6 +129,10 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
this._params = undefined; this._params = undefined;
} }
private get _firstStep(): Step {
return this._params?.skipWelcome ? STEPS[1] : STEPS[0];
}
private async _done() { private async _done() {
if (!this._config) { if (!this._config) {
return; return;
@ -187,7 +191,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
} }
const isLastStep = this._step === STEPS[STEPS.length - 1]; const isLastStep = this._step === STEPS[STEPS.length - 1];
const isFirstStep = this._step === STEPS[0]; const isFirstStep = this._step === this._firstStep;
return html` return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}> <ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
@ -396,7 +400,10 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
} }
private async _copyKeyToClipboard() { private async _copyKeyToClipboard() {
await copyToClipboard(this._config!.create_backup.password!); await copyToClipboard(
this._config!.create_backup.password!,
this.renderRoot.querySelector("div")!
);
showToast(this, { showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"), message: this.hass.localize("ui.common.copied_clipboard"),
}); });

View File

@ -92,7 +92,9 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
? "Save current encryption key" ? "Save current encryption key"
: this._step === "new" : this._step === "new"
? "New encryption key" ? "New encryption key"
: ""; : this._step === "done"
? "Save new encryption key"
: "";
return html` return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}> <ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
@ -166,10 +168,22 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
case "new": case "new":
return html` return html`
<p> <p>
Keep this encryption key in a safe place, as you will need it to All next backups will use the new encryption key. Encryption keeps
access your backup, allowing it to be restored. Either record the your backups private and secure.
</p>
<div class="encryption-key">
<p>${this._newEncryptionKey}</p>
<ha-icon-button
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
`;
case "done":
return html`<p>
Keep this new encryption key in a safe place, as you will need it to
access your backups, allowing it to be restored. Either record the
characters below or download them as an emergency kit file. characters below or download them as an emergency kit file.
Encryption keeps your backups private and secure.
</p> </p>
<div class="encryption-key"> <div class="encryption-key">
<p>${this._newEncryptionKey}</p> <p>${this._newEncryptionKey}</p>
@ -189,24 +203,16 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
Download Download
</ha-button> </ha-button>
</ha-md-list-item> </ha-md-list-item>
</ha-md-list> </ha-md-list>`;
`;
case "done":
return html`
<div class="done">
<img
src="/static/images/voice-assistant/hi.png"
alt="Casita Home Assistant logo"
/>
<p>Encryption key changed</p>
</div>
`;
} }
return nothing; return nothing;
} }
private async _copyKeyToClipboard() { private async _copyKeyToClipboard() {
await copyToClipboard(this._newEncryptionKey); await copyToClipboard(
this._newEncryptionKey,
this.renderRoot.querySelector("div")!
);
showToast(this, { showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"), message: this.hass.localize("ui.common.copied_clipboard"),
}); });
@ -216,7 +222,10 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
if (!this._params?.currentKey) { if (!this._params?.currentKey) {
return; return;
} }
await copyToClipboard(this._params.currentKey); await copyToClipboard(
this._params.currentKey,
this.renderRoot.querySelector("div")!
);
showToast(this, { showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"), message: this.hass.localize("ui.common.copied_clipboard"),
}); });
@ -297,13 +306,6 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
p { p {
margin-top: 0; margin-top: 0;
} }
.done {
text-align: center;
font-size: 22px;
font-style: normal;
font-weight: 400;
line-height: 28px;
}
`, `,
]; ];
} }

View File

@ -100,7 +100,10 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
this._agentIds = agents this._agentIds = agents
.map((agent) => agent.agent_id) .map((agent) => agent.agent_id)
.filter( .filter(
(id) => id !== CLOUD_AGENT || this._params?.cloudStatus?.logged_in (id) =>
id !== CLOUD_AGENT ||
(this._params?.cloudStatus?.logged_in &&
this._params?.cloudStatus?.active_subscription)
) )
.sort(compareAgents); .sort(compareAgents);
} }
@ -200,17 +203,36 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
? html` ? html`
<ha-button <ha-button
@click=${this._submit} @click=${this._submit}
.disabled=${!selectedAgents.length} .disabled=${this._formData.agents_mode === "custom" &&
!selectedAgents.length}
> >
Create backup Create backup
</ha-button> </ha-button>
` `
: html`<ha-button @click=${this._nextStep}>Next</ha-button>`} : html`<ha-button
@click=${this._nextStep}
.disabled=${this._step === "data" && this._noDataSelected}
>Next</ha-button
>`}
</div> </div>
</ha-md-dialog> </ha-md-dialog>
`; `;
} }
private get _noDataSelected() {
const hassio = isComponentLoaded(this.hass, "hassio");
if (
this._formData?.data.include_homeassistant ||
this._formData?.data.include_database ||
(hassio && this._formData?.data.include_folders?.length) ||
(hassio && this._formData?.data.include_all_addons) ||
(hassio && this._formData?.data.include_addons?.length)
) {
return false;
}
return true;
}
private _renderData() { private _renderData() {
if (!this._formData) { if (!this._formData) {
return nothing; return nothing;

View File

@ -23,7 +23,10 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup"; import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup";
import type { RestoreBackupStage } from "../../../../data/backup_manager"; import type {
RestoreBackupStage,
RestoreBackupState,
} from "../../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../../data/backup_manager"; import { subscribeBackupEvents } from "../../../../data/backup_manager";
type FormData = { type FormData = {
@ -52,8 +55,12 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
@state() private _userPassword?: string; @state() private _userPassword?: string;
@state() private _usedUserInput = false;
@state() private _error?: string; @state() private _error?: string;
@state() private _state?: RestoreBackupState;
@state() private _stage?: RestoreBackupStage | null; @state() private _stage?: RestoreBackupStage | null;
@state() private _unsub?: Promise<UnsubscribeFunc>; @state() private _unsub?: Promise<UnsubscribeFunc>;
@ -64,6 +71,11 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._params = params; this._params = params;
this._formData = INITIAL_DATA; this._formData = INITIAL_DATA;
this._userPassword = undefined;
this._usedUserInput = false;
this._error = undefined;
this._state = undefined;
this._stage = undefined;
if (this._params.backup.protected) { if (this._params.backup.protected) {
this._backupEncryptionKey = await this._fetchEncryptionKey(); this._backupEncryptionKey = await this._fetchEncryptionKey();
if (!this._backupEncryptionKey) { if (!this._backupEncryptionKey) {
@ -85,7 +97,9 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._params = undefined; this._params = undefined;
this._backupEncryptionKey = undefined; this._backupEncryptionKey = undefined;
this._userPassword = undefined; this._userPassword = undefined;
this._usedUserInput = false;
this._error = undefined; this._error = undefined;
this._state = undefined;
this._stage = undefined; this._stage = undefined;
this._step = undefined; this._step = undefined;
this._unsubscribe(); this._unsubscribe();
@ -149,15 +163,24 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
} }
private _renderEncryption() { private _renderEncryption() {
return html`<p> return html`${this._usedUserInput
${this._userPassword ? "The provided encryption key was incorrect, please try again."
? "The provided encryption key was incorrect, please try again." : this._backupEncryptionKey
: this._backupEncryptionKey ? html`The Backup is encrypted with a different encryption key than
? "The backup is encrypted with a different key or password than that is saved on this system. Please enter the key for this backup." that is saved on this system. Please enter the encryption key for
: "The backup is encrypted. Provide the encryption key to decrypt the backup."} this backup.<br />
</p> ${this._params!.selectedData.homeassistant_included
? html`<ha-alert alert-type="warning"
>After restoring the backup, your new backups will be
encrypted with the encryption key that was present during
the time of this backup.</ha-alert
>`
: nothing}`
: "The backup is encrypted. Provide the encryption key to decrypt the backup."}
<ha-password-field <ha-password-field
@change=${this._passwordChanged} @input=${this._passwordChanged}
label="Encryption key"
.value=${this._userPassword || ""} .value=${this._userPassword || ""}
></ha-password-field>`; ></ha-password-field>`;
} }
@ -186,16 +209,22 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
private async _restoreBackup() { private async _restoreBackup() {
this._unsubscribe(); this._unsubscribe();
this._state = undefined;
this._stage = undefined;
this._error = undefined;
try { try {
this._step = "progress"; this._step = "progress";
window.addEventListener("connection-status", this._connectionStatus);
this._subscribeBackupEvents(); this._subscribeBackupEvents();
await this._doRestoreBackup( await this._doRestoreBackup(
this._userPassword || this._backupEncryptionKey this._userPassword || this._backupEncryptionKey
); );
} catch (e: any) { } catch (e: any) {
this._unsubscribe(); await this._unsubscribe();
if (e.code === "password_incorrect") { if (e.code === "password_incorrect") {
this._error = undefined;
if (this._userPassword) {
this._usedUserInput = true;
}
this._step = "encryption"; this._step = "encryption";
} else { } else {
this._error = e.message; this._error = e.message;
@ -203,17 +232,15 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
} }
} }
private _connectionStatus = (ev) => {
if (ev.detail === "connected") {
this.closeDialog();
}
};
private _subscribeBackupEvents() { private _subscribeBackupEvents() {
this._unsub = subscribeBackupEvents(this.hass!, (event) => { this._unsub = subscribeBackupEvents(this.hass!, (event) => {
if (event.manager_state === "idle" && this._state === "in_progress") {
this.closeDialog();
}
if (event.manager_state !== "restore_backup") { if (event.manager_state !== "restore_backup") {
return; return;
} }
this._state = event.state;
if (event.state === "completed") { if (event.state === "completed") {
this.closeDialog(); this.closeDialog();
} }
@ -227,11 +254,12 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
} }
private _unsubscribe() { private _unsubscribe() {
window.removeEventListener("connection-status", this._connectionStatus);
if (this._unsub) { if (this._unsub) {
this._unsub.then((unsub) => unsub()); const prom = this._unsub.then((unsub) => unsub());
this._unsub = undefined; this._unsub = undefined;
return prom;
} }
return undefined;
} }
private _restoreState() { private _restoreState() {
@ -306,6 +334,14 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
ha-circular-progress { ha-circular-progress {
margin-bottom: 16px; margin-bottom: 16px;
} }
ha-alert[alert-type="warning"] {
display: block;
margin-top: 16px;
}
ha-password-field {
display: block;
margin-top: 16px;
}
`, `,
]; ];
} }

View File

@ -0,0 +1,174 @@
import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-password-field";
import { downloadEmergencyKit } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import type { ShowBackupEncryptionKeyDialogParams } from "./show-dialog-show-backup-encryption-key";
@customElement("ha-dialog-show-backup-encryption-key")
class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ShowBackupEncryptionKeyDialogParams;
@query("ha-md-dialog") private _dialog!: HaMdDialog;
public showDialog(params: ShowBackupEncryptionKeyDialogParams): void {
this._params = params;
}
public closeDialog(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _closeDialog() {
this._dialog.close();
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this._closeDialog}
></ha-icon-button>
<span slot="title">Encryption key</span>
</ha-dialog-header>
<div slot="content">
<p>
Make sure you save the encryption key in a secure place so always
have access to your backups.
</p>
<div class="encryption-key">
<p>${this._params?.currentKey}</p>
<ha-icon-button
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Download emergency kit</span>
<span slot="supporting-text">
We recommend saving this encryption key file somewhere secure.
</span>
<ha-button slot="end" @click=${this._download}>
<ha-svg-icon .path=${mdiDownload} slot="icon"></ha-svg-icon>
Download
</ha-button>
</ha-md-list-item>
</ha-md-list>
</div>
<div slot="actions">
<ha-button @click=${this._closeDialog}>Close</ha-button>
</div>
</ha-md-dialog>
`;
}
private async _copyKeyToClipboard() {
if (!this._params?.currentKey) {
return;
}
await copyToClipboard(
this._params?.currentKey,
this.renderRoot.querySelector("div")!
);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _download() {
if (!this._params?.currentKey) {
return;
}
downloadEmergencyKit(this.hass, this._params.currentKey, "old");
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-md-dialog {
width: 90vw;
max-width: 560px;
--dialog-content-padding: 8px 24px;
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-button.danger {
--mdc-theme-primary: var(--error-color);
}
.encryption-key {
border: 1px solid var(--divider-color);
background-color: var(--primary-background-color);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
}
.encryption-key p {
margin: 0;
flex: 1;
font-family: "Roboto Mono", "Consolas", "Menlo", monospace;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 28px;
text-align: center;
}
.encryption-key ha-icon-button {
flex: none;
margin: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
p {
margin-top: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-show-backup-encryption-key": DialogShowBackupEncryptionKey;
}
}

View File

@ -20,7 +20,6 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers"; import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import "../components/ha-backup-agents-picker";
import type { UploadBackupDialogParams } from "./show-dialog-upload-backup"; import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
const SUPPORTED_FORMAT = "application/x-tar"; const SUPPORTED_FORMAT = "application/x-tar";
@ -90,6 +89,9 @@ export class DialogUploadBackup
<span slot="title">Upload backup</span> <span slot="title">Upload backup</span>
</ha-dialog-header> </ha-dialog-header>
<div slot="content"> <div slot="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload <ha-file-upload
.hass=${this.hass} .hass=${this.hass}
.uploading=${this._uploading} .uploading=${this._uploading}
@ -99,9 +101,6 @@ export class DialogUploadBackup
supports="Supports .tar files" supports="Supports .tar files"
@file-picked=${this._filePicked} @file-picked=${this._filePicked}
></ha-file-upload> ></ha-file-upload>
${this._error
? html`<ha-alert alertType="error">${this._error}</ha-alert>`
: nothing}
</div> </div>
<div slot="actions"> <div slot="actions">
<ha-button @click=${this.closeDialog}>Cancel</ha-button> <ha-button @click=${this.closeDialog}>Cancel</ha-button>
@ -161,6 +160,10 @@ export class DialogUploadBackup
max-width: 500px; max-width: 500px;
max-height: 100%; max-height: 100%;
} }
ha-alert {
display: block;
margin-bottom: 16px;
}
`, `,
]; ];
} }

View File

@ -5,6 +5,7 @@ export interface BackupOnboardingDialogParams {
submit?: (value: boolean) => void; submit?: (value: boolean) => void;
cancel?: () => void; cancel?: () => void;
cloudStatus?: CloudStatus; cloudStatus?: CloudStatus;
skipWelcome?: boolean;
} }
const loadDialog = () => import("./dialog-backup-onboarding"); const loadDialog = () => import("./dialog-backup-onboarding");

View File

@ -0,0 +1,17 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface ShowBackupEncryptionKeyDialogParams {
currentKey: string;
}
const loadDialog = () => import("./dialog-show-backup-encryption-key");
export const showShowBackupEncryptionKeyDialog = (
element: HTMLElement,
params?: ShowBackupEncryptionKeyDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-show-backup-encryption-key",
dialogImport: loadDialog,
dialogParams: params,
});

View File

@ -301,10 +301,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
return html` return html`
<hass-tabs-subpage-data-table <hass-tabs-subpage-data-table
hasFab has-fab
.tabs=${[ .tabs=${[
{ {
translationKey: "ui.panel.config.backup.caption", name: "My backups",
path: `/config/backup/list`, path: `/config/backup/list`,
}, },
]} ]}

View File

@ -143,6 +143,14 @@ class HaConfigBackupDetails extends LitElement {
)} )}
<span slot="supporting-text">Created</span> <span slot="supporting-text">Created</span>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this._backup.protected
? "Encrypted AES-128"
: "Not encrypted"}
</span>
<span slot="supporting-text">Protected</span>
</ha-md-list-item>
</ha-md-list> </ha-md-list>
</div> </div>
</ha-card> </ha-card>

View File

@ -1,8 +1,7 @@
import { mdiDotsVertical, mdiHarddisk, mdiPlus, mdiUpload } from "@mdi/js"; import { mdiDotsVertical, mdiPlus, mdiUpload } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-button"; import "../../../components/ha-button";
@ -33,7 +32,6 @@ import "./components/overview/ha-backup-overview-settings";
import "./components/overview/ha-backup-overview-summary"; import "./components/overview/ha-backup-overview-summary";
import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding"; import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding";
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup"; import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup"; import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup"; import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
@ -63,22 +61,15 @@ class HaConfigBackupOverview extends LitElement {
await showUploadBackupDialog(this, {}); await showUploadBackupDialog(this, {});
} }
private async _changeLocalLocation(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
showLocalBackupLocationDialog(this, {});
}
private _handleOnboardingButtonClick(ev) { private _handleOnboardingButtonClick(ev) {
ev.stopPropagation(); ev.stopPropagation();
this._setupAutomaticBackup(); this._setupAutomaticBackup(true);
} }
private async _setupAutomaticBackup() { private async _setupAutomaticBackup(skipWelcome = false) {
const success = await showBackupOnboardingDialog(this, { const success = await showBackupOnboardingDialog(this, {
cloudStatus: this.cloudStatus, cloudStatus: this.cloudStatus,
skipWelcome,
}); });
if (!success) { if (!success) {
return; return;
@ -127,15 +118,13 @@ class HaConfigBackupOverview extends LitElement {
} }
private get _needsOnboarding() { private get _needsOnboarding() {
return !this.config?.create_backup.password; return this.config && !this.config.create_backup.password;
} }
protected render(): TemplateResult { protected render(): TemplateResult {
const backupInProgress = const backupInProgress =
"state" in this.manager && this.manager.state === "in_progress"; "state" in this.manager && this.manager.state === "in_progress";
const isHassio = isComponentLoaded(this.hass, "hassio");
return html` return html`
<hass-subpage <hass-subpage
back-path="/config/system" back-path="/config/system"
@ -143,34 +132,17 @@ class HaConfigBackupOverview extends LitElement {
.narrow=${this.narrow} .narrow=${this.narrow}
.header=${"Backup"} .header=${"Backup"}
> >
<div slot="toolbar-icon"> <ha-button-menu slot="toolbar-icon">
<ha-button-menu> <ha-icon-button
<ha-icon-button slot="trigger"
slot="trigger" .label=${this.hass.localize("ui.common.menu")}
.label=${this.hass.localize("ui.common.menu")} .path=${mdiDotsVertical}
.path=${mdiDotsVertical} ></ha-icon-button>
></ha-icon-button> <ha-list-item graphic="icon" @request-selected=${this._uploadBackup}>
${isHassio <ha-svg-icon slot="graphic" .path=${mdiUpload}></ha-svg-icon>
? html`<ha-list-item Upload backup
graphic="icon" </ha-list-item>
@request-selected=${this._changeLocalLocation} </ha-button-menu>
>
<ha-svg-icon
slot="graphic"
.path=${mdiHarddisk}
></ha-svg-icon>
Change local location
</ha-list-item>`
: nothing}
<ha-list-item
graphic="icon"
@request-selected=${this._uploadBackup}
>
<ha-svg-icon slot="graphic" .path=${mdiUpload}></ha-svg-icon>
Upload backup
</ha-list-item>
</ha-button-menu>
</div>
<div class="content"> <div class="content">
${backupInProgress ${backupInProgress
? html` ? html`
@ -188,22 +160,24 @@ class HaConfigBackupOverview extends LitElement {
> >
</ha-backup-overview-onboarding> </ha-backup-overview-onboarding>
` `
: html` : this.config
<ha-backup-overview-summary ? html`
.hass=${this.hass} <ha-backup-overview-summary
.backups=${this.backups} .hass=${this.hass}
.config=${this.config} .backups=${this.backups}
.fetching=${this.fetching} .config=${this.config}
> .fetching=${this.fetching}
</ha-backup-overview-summary> >
`} </ha-backup-overview-summary>
`
: nothing}
<ha-backup-overview-backups <ha-backup-overview-backups
.hass=${this.hass} .hass=${this.hass}
.backups=${this.backups} .backups=${this.backups}
></ha-backup-overview-backups> ></ha-backup-overview-backups>
${!this._needsOnboarding ${!this._needsOnboarding && this.config
? html` ? html`
<ha-backup-overview-settings <ha-backup-overview-settings
.hass=${this.hass} .hass=${this.hass}

View File

@ -1,14 +1,21 @@
import { mdiDotsVertical, mdiHarddisk } from "@mdi/js";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { debounce } from "../../../common/util/debounce"; import { debounce } from "../../../common/util/debounce";
import { nextRender } from "../../../common/util/render-status"; import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next"; import "../../../components/ha-icon-next";
import "../../../components/ha-list-item";
import "../../../components/ha-alert";
import "../../../components/ha-password-field"; import "../../../components/ha-password-field";
import "../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../data/backup"; import type { BackupConfig } from "../../../data/backup";
import { updateBackupConfig } from "../../../data/backup"; import { updateBackupConfig } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud"; import type { CloudStatus } from "../../../data/cloud";
@ -20,6 +27,7 @@ import type { BackupConfigData } from "./components/config/ha-backup-config-data
import "./components/config/ha-backup-config-encryption-key"; import "./components/config/ha-backup-config-encryption-key";
import "./components/config/ha-backup-config-schedule"; import "./components/config/ha-backup-config-schedule";
import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule"; import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule";
import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location";
@customElement("ha-config-backup-settings") @customElement("ha-config-backup-settings")
class HaConfigBackupSettings extends LitElement { class HaConfigBackupSettings extends LitElement {
@ -91,8 +99,30 @@ class HaConfigBackupSettings extends LitElement {
back-path="/config/backup" back-path="/config/backup"
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
.header=${"Automatic backups"} .header=${"Backup settings"}
> >
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-button-menu slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
@request-selected=${this._changeLocalLocation}
>
<ha-svg-icon
slot="graphic"
.path=${mdiHarddisk}
></ha-svg-icon>
Change default action location
</ha-list-item>
</ha-button-menu>
`
: nothing}
<div class="content"> <div class="content">
<ha-card id="schedule"> <ha-card id="schedule">
<div class="card-header">Automatic backups</div> <div class="card-header">Automatic backups</div>
@ -134,6 +164,14 @@ class HaConfigBackupSettings extends LitElement {
.cloudStatus=${this.cloudStatus} .cloudStatus=${this.cloudStatus}
@value-changed=${this._agentsConfigChanged} @value-changed=${this._agentsConfigChanged}
></ha-backup-config-agents> ></ha-backup-config-agents>
${!this._config.create_backup.agent_ids.length
? html`<ha-alert
alert-type="warning"
title="No location selected"
>You have to select at least one location to create a
backup.</ha-alert
><br />`
: nothing}
</div> </div>
</ha-card> </ha-card>
<ha-card> <ha-card>
@ -157,6 +195,14 @@ class HaConfigBackupSettings extends LitElement {
`; `;
} }
private async _changeLocalLocation(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
showLocalBackupLocationDialog(this, {});
}
private _scheduleConfigChanged(ev) { private _scheduleConfigChanged(ev) {
const value = ev.detail.value as BackupConfigSchedule; const value = ev.detail.value as BackupConfigSchedule;
this._config = { this._config = {

View File

@ -33,7 +33,7 @@ declare global {
class HaConfigBackup extends SubscribeMixin(HassRouterPage) { class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatus; @property({ attribute: false }) public cloudStatus?: CloudStatus;
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;

View File

@ -32,6 +32,7 @@ import { brandsUrl } from "../../../util/brands-url";
import type { Helper, HelperDomain } from "./const"; import type { Helper, HelperDomain } from "./const";
import { isHelperDomain } from "./const"; import { isHelperDomain } from "./const";
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
import { fireEvent } from "../../../common/dom/fire_event";
type HelperCreators = { type HelperCreators = {
[domain in HelperDomain]: { [domain in HelperDomain]: {
@ -129,6 +130,7 @@ export class DialogHelperDetail extends LitElement {
this._error = undefined; this._error = undefined;
this._domain = undefined; this._domain = undefined;
this._params = undefined; this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
protected render() { protected render() {

View File

@ -177,7 +177,7 @@ export class HaConfigLovelaceRescources extends LitElement {
.filter=${this._filter} .filter=${this._filter}
@search-changed=${this._handleSearchChange} @search-changed=${this._handleSearchChange}
@row-click=${this._editResource} @row-click=${this._editResource}
hasFab has-fab
clickable clickable
> >
<ha-fab <ha-fab

View File

@ -525,6 +525,9 @@ export class HassioNetwork extends LitElement {
IP_VERSIONS.forEach((version) => { IP_VERSIONS.forEach((version) => {
interfaceOptions[version] = { interfaceOptions[version] = {
method: this._interface![version]?.method || "auto", method: this._interface![version]?.method || "auto",
nameservers: this._interface![version]?.nameservers?.filter(
(ns: string) => ns.trim()
),
}; };
if (this._interface![version]?.method === "static") { if (this._interface![version]?.method === "static") {
interfaceOptions[version] = { interfaceOptions[version] = {
@ -533,9 +536,6 @@ export class HassioNetwork extends LitElement {
(address: string) => address.trim() (address: string) => address.trim()
), ),
gateway: this._interface![version]?.gateway, gateway: this._interface![version]?.gateway,
nameservers: this._interface![version]?.nameservers?.filter(
(ns: string) => ns.trim()
),
}; };
} }
}); });

View File

@ -180,7 +180,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
</ha-settings-row> </ha-settings-row>
${this._renderUserFields()} ${this._renderUserFields()}
${!this._deviceTrackersAvailable(this.hass) ${this._deviceTrackersAvailable(this.hass)
? html` ? html`
<p> <p>
${this.hass.localize( ${this.hass.localize(

View File

@ -26,6 +26,7 @@ import { navigate } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify"; import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl"; import { computeRTL } from "../../../common/util/compute_rtl";
import { afterNextRender } from "../../../common/util/render-status"; import { afterNextRender } from "../../../common/util/render-status";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
@ -915,17 +916,49 @@ export class HaScriptEditor extends SubscribeMixin(
// wait for new script to appear in entity registry // wait for new script to appear in entity registry
if (entityRegPromise) { if (entityRegPromise) {
const script = await entityRegPromise; try {
entityId = script.entity_id; const script = await promiseTimeout(2000, entityRegPromise);
entityId = script.entity_id;
} catch (e) {
entityId = undefined;
if (e instanceof Error && e.name === "TimeoutError") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.new_automation_setup_failed_title",
{
type: this.hass.localize(
"ui.panel.config.automation.editor.type_script"
),
}
),
text: this.hass.localize(
"ui.panel.config.automation.editor.new_automation_setup_failed_text",
{
type: this.hass.localize(
"ui.panel.config.automation.editor.type_script"
),
types: this.hass.localize(
"ui.panel.config.automation.editor.type_script_plural"
),
}
),
warning: true,
});
} else {
throw e;
}
}
} }
await updateEntityRegistryEntry(this.hass, entityId!, { if (entityId) {
categories: { await updateEntityRegistryEntry(this.hass, entityId, {
script: this._entityRegistryUpdate.category || null, categories: {
}, script: this._entityRegistryUpdate.category || null,
labels: this._entityRegistryUpdate.labels || [], },
area_id: this._entityRegistryUpdate.area || null, labels: this._entityRegistryUpdate.labels || [],
}); area_id: this._entityRegistryUpdate.area || null,
});
}
} }
this._dirty = false; this._dirty = false;
@ -934,9 +967,9 @@ export class HaScriptEditor extends SubscribeMixin(
navigate(`/config/script/edit/${id}`, { replace: true }); navigate(`/config/script/edit/${id}`, { replace: true });
} }
} catch (errors: any) { } catch (errors: any) {
this._errors = errors.body.message || errors.error || errors.body; this._errors = errors.body?.message || errors.error || errors.body;
showToast(this, { showToast(this, {
message: errors.body.message || errors.error || errors.body, message: errors.body?.message || errors.error || errors.body,
}); });
throw errors; throw errors;
} finally { } finally {

View File

@ -295,9 +295,6 @@ export default class HaScriptFieldRow extends LitElement {
ha-icon-button { ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
} }
ha-card {
overflow: hidden;
}
.disabled { .disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
@ -330,6 +327,8 @@ export default class HaScriptFieldRow extends LitElement {
.disabled-bar { .disabled-bar {
background: var(--divider-color, #e0e0e0); background: var(--divider-color, #e0e0e0);
text-align: center; text-align: center;
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
} }
ha-list-item[disabled] { ha-list-item[disabled] {

View File

@ -16,7 +16,7 @@ import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles"; import { cardFeatureStyles } from "./common/card-feature-styles";
import type { UpdateActionsCardFeatureConfig } from "./types"; import type { UpdateActionsCardFeatureConfig } from "./types";
export const DEFAULT_UPDATE_BACKUP_OPTION = "ask"; export const DEFAULT_UPDATE_BACKUP_OPTION = "no";
export const supportsUpdateActionsCardFeature = (stateObj: HassEntity) => { export const supportsUpdateActionsCardFeature = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id); const domain = computeDomain(stateObj.entity_id);

View File

@ -16,7 +16,7 @@ import { getSensorNumericDeviceClasses } from "../../../data/sensor";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { hasConfigOrEntitiesChanged } from "../common/has-changed"; import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities"; import { processConfigEntities } from "../common/process-config-entities";
import type { LovelaceCard } from "../types"; import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HistoryGraphCardConfig } from "./types"; import type { HistoryGraphCardConfig } from "./types";
import { createSearchParam } from "../../../common/url/search-params"; import { createSearchParam } from "../../../common/url/search-params";
@ -56,6 +56,14 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
return this._config?.title ? 2 : 0 + 2 * (this._entityIds?.length || 1); return this._config?.title ? 2 : 0 + 2 * (this._entityIds?.length || 1);
} }
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
min_rows: (this._config?.entities?.length || 1) * 2,
};
}
public setConfig(config: HistoryGraphCardConfig): void { public setConfig(config: HistoryGraphCardConfig): void {
if (!config.entities || !Array.isArray(config.entities)) { if (!config.entities || !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array"); throw new Error("Entities need to be an array");

View File

@ -18,7 +18,7 @@ import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities"; import { findEntities } from "../common/find-entities";
import { hasConfigOrEntitiesChanged } from "../common/has-changed"; import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities"; import { processConfigEntities } from "../common/process-config-entities";
import type { LovelaceCard } from "../types"; import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { StatisticsGraphCardConfig } from "./types"; import type { StatisticsGraphCardConfig } from "./types";
export const DEFAULT_DAYS_TO_SHOW = 30; export const DEFAULT_DAYS_TO_SHOW = 30;
@ -93,6 +93,14 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
); );
} }
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 9,
min_rows: 4,
};
}
public setConfig(config: StatisticsGraphCardConfig): void { public setConfig(config: StatisticsGraphCardConfig): void {
if (!config.entities || !Array.isArray(config.entities)) { if (!config.entities || !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array"); throw new Error("Entities need to be an array");

View File

@ -38,7 +38,7 @@ export class HuiUpdateActionsCardFeatureEditor
disabled: !supportsBackup, disabled: !supportsBackup,
selector: { selector: {
select: { select: {
default: "yes", default: "no",
mode: "dropdown", mode: "dropdown",
options: ["ask", "yes", "no"].map((option) => ({ options: ["ask", "yes", "no"].map((option) => ({
value: option, value: option,

View File

@ -37,7 +37,7 @@ export class HuiViewBackgroundEditor extends LitElement {
type: "expandable" as const, type: "expandable" as const,
schema: [ schema: [
{ {
name: "transparency", name: "opacity",
selector: { selector: {
number: { min: 1, max: 100, mode: "slider" }, number: { min: 1, max: 100, mode: "slider" },
}, },
@ -117,7 +117,7 @@ export class HuiViewBackgroundEditor extends LitElement {
if (!background) { if (!background) {
background = { background = {
transparency: 33, opacity: 33,
alignment: "center", alignment: "center",
size: "cover", size: "cover",
repeat: "repeat", repeat: "repeat",
@ -125,7 +125,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}; };
} else { } else {
background = { background = {
transparency: 100, opacity: 100,
alignment: "center", alignment: "center",
size: "cover", size: "cover",
repeat: "no-repeat", repeat: "no-repeat",
@ -162,9 +162,9 @@ export class HuiViewBackgroundEditor extends LitElement {
return this.hass.localize( return this.hass.localize(
"ui.panel.lovelace.editor.edit_view.background.image" "ui.panel.lovelace.editor.edit_view.background.image"
); );
case "transparency": case "opacity":
return this.hass.localize( return this.hass.localize(
"ui.panel.lovelace.editor.edit_view.background.transparency" "ui.panel.lovelace.editor.edit_view.background.opacity"
); );
case "alignment": case "alignment":
return this.hass.localize( return this.hass.localize(

View File

@ -67,8 +67,8 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig background?: string | LovelaceViewBackgroundConfig
) { ) {
if (typeof background === "object" && background.image) { if (typeof background === "object" && background.image) {
if (background.transparency) { if (background.opacity) {
return `${background.transparency}%`; return `${background.opacity}%`;
} }
} }
return null; return null;

View File

@ -16,7 +16,7 @@ import "../../layouts/hass-error-screen";
import type { HomeAssistant, Route } from "../../types"; import type { HomeAssistant, Route } from "../../types";
import { documentationUrl } from "../../util/documentation-url"; import { documentationUrl } from "../../util/documentation-url";
export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({ export const getMyRedirects = (): Redirects => ({
application_credentials: { application_credentials: {
redirect: "/config/application_credentials", redirect: "/config/application_credentials",
}, },
@ -244,16 +244,24 @@ export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({
redirect: "/media-browser", redirect: "/media-browser",
}, },
backup: { backup: {
component: hasSupervisor ? "hassio" : "backup", component: "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", redirect: "/config/backup",
},
backup_list: {
component: "backup",
redirect: "/config/backup/backups",
},
backup_config: {
component: "backup",
redirect: "/config/backup/settings",
}, },
supervisor_snapshots: { supervisor_snapshots: {
component: hasSupervisor ? "hassio" : "backup", component: "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", redirect: "/config/backup",
}, },
supervisor_backups: { supervisor_backups: {
component: hasSupervisor ? "hassio" : "backup", component: "backup",
redirect: hasSupervisor ? "/hassio/backups" : "/config/backup", redirect: "/config/backup",
}, },
supervisor_system: { supervisor_system: {
// Moved from Supervisor panel in 2022.5 // Moved from Supervisor panel in 2022.5
@ -278,10 +286,8 @@ export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({
}, },
}); });
const getRedirect = ( const getRedirect = (path: string): Redirect | undefined =>
path: string, getMyRedirects()?.[path];
hasSupervisor: boolean
): Redirect | undefined => getMyRedirects(hasSupervisor)?.[path];
export type ParamType = "url" | "string" | "string?"; export type ParamType = "url" | "string" | "string?";
@ -314,7 +320,7 @@ class HaPanelMy extends LitElement {
const path = this.route.path.substring(1); const path = this.route.path.substring(1);
const hasSupervisor = isComponentLoaded(this.hass, "hassio"); const hasSupervisor = isComponentLoaded(this.hass, "hassio");
this._redirect = getRedirect(path, hasSupervisor); this._redirect = getRedirect(path);
if (path.startsWith("supervisor") && this._redirect === undefined) { if (path.startsWith("supervisor") && this._redirect === undefined) {
if (!hasSupervisor) { if (!hasSupervisor) {

View File

@ -150,9 +150,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
const myPanel = await import("../panels/my/ha-panel-my"); const myPanel = await import("../panels/my/ha-panel-my");
for (const [slug, redirect] of Object.entries( for (const [slug, redirect] of Object.entries(myPanel.getMyRedirects())) {
myPanel.getMyRedirects(isHassio)
)) {
if (targetPath.startsWith(redirect.redirect)) { if (targetPath.startsWith(redirect.redirect)) {
myParams.append("redirect", slug); myParams.append("redirect", slug);
if (redirect.params) { if (redirect.params) {

View File

@ -651,6 +651,7 @@
"no_devices": "You don't have any devices", "no_devices": "You don't have any devices",
"no_match": "No matching devices found", "no_match": "No matching devices found",
"device": "Device", "device": "Device",
"unnamed_device": "Unnamed device",
"no_area": "No area" "no_area": "No area"
}, },
"category-picker": { "category-picker": {
@ -755,7 +756,7 @@
}, },
"picture-upload": { "picture-upload": {
"label": "Add picture", "label": "Add picture",
"change_picture": "Change picture", "clear_picture": "Clear picture",
"current_image_alt": "Current picture", "current_image_alt": "Current picture",
"supported_formats": "Supports JPEG, PNG, or GIF image.", "supported_formats": "Supports JPEG, PNG, or GIF image.",
"unsupported_format": "Unsupported format, please choose a JPEG, PNG, or GIF image.", "unsupported_format": "Unsupported format, please choose a JPEG, PNG, or GIF image.",
@ -830,7 +831,8 @@
"source_history": "Source: History", "source_history": "Source: History",
"source_stats": "Source: Long term statistics", "source_stats": "Source: Long term statistics",
"zoom_hint": "Use ctrl + scroll to zoom in/out", "zoom_hint": "Use ctrl + scroll to zoom in/out",
"zoom_hint_mac": "Use ⌘ + scroll to zoom in/out" "zoom_hint_mac": "Use ⌘ + scroll to zoom in/out",
"zoom_reset": "Reset zoom"
}, },
"map": { "map": {
"error": "Unable to load map" "error": "Unable to load map"
@ -2213,7 +2215,7 @@
}, },
"dialogs": { "dialogs": {
"local_backup_location": { "local_backup_location": {
"title": "Change local backup location", "title": "Change default local backup location",
"description": "Change the default location where local backups are stored on your Home Assistant instance.", "description": "Change the default location where local backups are stored on your Home Assistant instance.",
"note": "This location will be used when you create a backup using the supervisor actions in an automation for example.", "note": "This location will be used when you create a backup using the supervisor actions in an automation for example.",
"options": { "options": {
@ -3063,6 +3065,12 @@
"unknown_entity": "unknown entity", "unknown_entity": "unknown entity",
"edit_unknown_device": "Editor not available for unknown device", "edit_unknown_device": "Editor not available for unknown device",
"switch_ui_yaml_error": "There are currently YAML errors in the automation, and it cannot be parsed. Switching to UI mode may cause pending changes to be lost. Press cancel to correct any errors before proceeding to prevent loss of pending changes, or continue if you are sure.", "switch_ui_yaml_error": "There are currently YAML errors in the automation, and it cannot be parsed. Switching to UI mode may cause pending changes to be lost. Press cancel to correct any errors before proceeding to prevent loss of pending changes, or continue if you are sure.",
"type_automation": "automation",
"type_script": "script",
"type_automation_plural": "[%key:ui::panel::config::blueprint::overview::types_plural::automation%]",
"type_script_plural": "[%key:ui::panel::config::blueprint::overview::types_plural::script%]",
"new_automation_setup_failed_title": "New {type} setup failed",
"new_automation_setup_failed_text": "Your new {type} has saved, but waiting for it to setup has timed out. This could be due to errors parsing your configuration.yaml, please check the configuration in developer tools. Your {type} will not be visible until this is corrected, and {types} are reloaded. Changes to area, category, or labels were not saved and must be reapplied.",
"triggers": { "triggers": {
"name": "Triggers", "name": "Triggers",
"header": "When", "header": "When",
@ -5955,7 +5963,7 @@
"bottom right": "Bottom right" "bottom right": "Bottom right"
} }
}, },
"transparency": "Background transparency", "opacity": "Background opacity",
"repeat": { "repeat": {
"name": "Background repeat", "name": "Background repeat",
"options": { "options": {

View File

@ -1801,6 +1801,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@gfx/zopfli@npm:^1.0.15":
version: 1.0.15
resolution: "@gfx/zopfli@npm:1.0.15"
dependencies:
base64-js: "npm:^1.3.0"
checksum: 10/2721ad8c0cbbdac7d5ca9e01ad05f232b4e3cdcecf88f9b0ef9a2bdc7d05e1ca54ea68905430cf36338ef1077acec178aa7b258c67baa7a4c2b6d74067605723
languageName: node
linkType: hard
"@gulpjs/messages@npm:^1.1.0": "@gulpjs/messages@npm:^1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "@gulpjs/messages@npm:1.1.0" resolution: "@gulpjs/messages@npm:1.1.0"
@ -5684,6 +5693,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"any-promise@npm:^1.1.0":
version: 1.3.0
resolution: "any-promise@npm:1.3.0"
checksum: 10/6737469ba353b5becf29e4dc3680736b9caa06d300bda6548812a8fee63ae7d336d756f88572fa6b5219aed36698d808fa55f62af3e7e6845c7a1dc77d240edb
languageName: node
linkType: hard
"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": "anymatch@npm:^3.1.3, anymatch@npm:~3.1.2":
version: 3.1.3 version: 3.1.3
resolution: "anymatch@npm:3.1.3" resolution: "anymatch@npm:3.1.3"
@ -6030,7 +6046,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"base64-js@npm:^1.3.1": "base64-js@npm:^1.3.0, base64-js@npm:^1.3.1":
version: 1.5.1 version: 1.5.1
resolution: "base64-js@npm:1.5.1" resolution: "base64-js@npm:1.5.1"
checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
@ -6237,7 +6253,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"bytes@npm:3.1.2": "bytes@npm:3.1.2, bytes@npm:^3.1.2":
version: 3.1.2 version: 3.1.2
resolution: "bytes@npm:3.1.2" resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@ -7070,7 +7086,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"defaults@npm:^1.0.3": "defaults@npm:^1.0.3, defaults@npm:^1.0.4":
version: 1.0.4 version: 1.0.4
resolution: "defaults@npm:1.0.4" resolution: "defaults@npm:1.0.4"
dependencies: dependencies:
@ -8180,7 +8196,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fancy-log@npm:2.0.0": "fancy-log@npm:2.0.0, fancy-log@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "fancy-log@npm:2.0.0" resolution: "fancy-log@npm:2.0.0"
dependencies: dependencies:
@ -8973,6 +8989,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"gulp-zopfli-green@npm:6.0.2":
version: 6.0.2
resolution: "gulp-zopfli-green@npm:6.0.2"
dependencies:
"@gfx/zopfli": "npm:^1.0.15"
bytes: "npm:^3.1.2"
defaults: "npm:^1.0.4"
fancy-log: "npm:^2.0.0"
plugin-error: "npm:^2.0.1"
stream-to-array: "npm:^2.3.0"
through2: "npm:^4.0.2"
checksum: 10/52e899dfb86777ff8f97a23af99c59e203ea485fbf04d0a8f4f1cfbd4d4c496808a3593ae8dac16584fc4b4d81cf127b2eda5355a61bcc213875c95cc86d41da
languageName: node
linkType: hard
"gulp@npm:5.0.0": "gulp@npm:5.0.0":
version: 5.0.0 version: 5.0.0
resolution: "gulp@npm:5.0.0" resolution: "gulp@npm:5.0.0"
@ -9254,6 +9285,7 @@ __metadata:
gulp-brotli: "npm:3.0.0" gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0" gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.0.0" gulp-rename: "npm:2.0.0"
gulp-zopfli-green: "npm:6.0.2"
hls.js: "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch" hls.js: "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch"
home-assistant-js-websocket: "npm:9.4.0" home-assistant-js-websocket: "npm:9.4.0"
html-minifier-terser: "npm:7.2.0" html-minifier-terser: "npm:7.2.0"
@ -12161,6 +12193,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"plugin-error@npm:^2.0.1":
version: 2.0.1
resolution: "plugin-error@npm:2.0.1"
dependencies:
ansi-colors: "npm:^1.0.1"
checksum: 10/9a4f91461cd24cce401112098969991d7aa6b4c94f78e0381234280c07da779570a8b21ab143292b534ec0117c09705a67e5d756c1c303d4706fdd7f861bf5bc
languageName: node
linkType: hard
"pngjs@npm:^3.0.0, pngjs@npm:^3.3.3": "pngjs@npm:^3.0.0, pngjs@npm:^3.3.3":
version: 3.4.0 version: 3.4.0
resolution: "pngjs@npm:3.4.0" resolution: "pngjs@npm:3.4.0"
@ -12383,7 +12424,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:^3.0.6, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": "readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.0.6, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0":
version: 3.6.2 version: 3.6.2
resolution: "readable-stream@npm:3.6.2" resolution: "readable-stream@npm:3.6.2"
dependencies: dependencies:
@ -13566,6 +13607,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"stream-to-array@npm:^2.3.0":
version: 2.3.0
resolution: "stream-to-array@npm:2.3.0"
dependencies:
any-promise: "npm:^1.1.0"
checksum: 10/7feaf63b38399b850615e6ffcaa951e96e4c8f46745dbce4b553a94c5dc43966933813747014935a3ff97793e7f30a65270bde19f82b2932871a1879229a77cf
languageName: node
linkType: hard
"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0": "streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0":
version: 2.21.1 version: 2.21.1
resolution: "streamx@npm:2.21.1" resolution: "streamx@npm:2.21.1"
@ -13993,6 +14043,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"through2@npm:^4.0.2":
version: 4.0.2
resolution: "through2@npm:4.0.2"
dependencies:
readable-stream: "npm:3"
checksum: 10/72c246233d9a989bbebeb6b698ef0b7b9064cb1c47930f79b25d87b6c867e075432811f69b7b2ac8da00ca308191c507bdab913944be8019ac43b036ce88f6ba
languageName: node
linkType: hard
"thunky@npm:^1.0.2": "thunky@npm:^1.0.2":
version: 1.1.0 version: 1.1.0
resolution: "thunky@npm:1.1.0" resolution: "thunky@npm:1.1.0"