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

View File

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

View File

@ -115,6 +115,7 @@
"element-internals-polyfill": "1.3.12",
"fuse.js": "7.0.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",
"home-assistant-js-websocket": "9.4.0",
"idb-keyval": "6.2.1",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20241224.0"
version = "20250103.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
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
* @param value - The value to ensure is an array
*/
export function ensureArray(value: undefined): undefined;
export function ensureArray<T>(value: T | T[]): NonUndefined<T>[];
export function ensureArray<T>(value: T | readonly T[]): NonUndefined<T>[];
export function ensureArray(value: null): null;
export function ensureArray<T>(value: T | T[]): NonNullUndefined<T>[];
export function ensureArray<T>(value: T | readonly T[]): NonNullUndefined<T>[];
export function ensureArray(value) {
if (value === undefined || Array.isArray(value)) {
if (value === undefined || value === null || Array.isArray(value)) {
return value;
}
return [value];

View File

@ -14,9 +14,16 @@ export interface NavigateOptions {
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;
if (history.state?.dialog) {
if (history.state?.dialog && Date.now() - timestamp < DIALOG_WAIT_TIMEOUT) {
const closed = await closeAllDialogs();
if (!closed) {
// eslint-disable-next-line no-console
@ -26,7 +33,7 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
return new Promise<boolean>((resolve) => {
// need to wait for history state to be updated in case a dialog was closed
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) {
try {
await navigator.clipboard.writeText(str);
@ -8,10 +8,12 @@ export const copyToClipboard = async (str) => {
}
}
const root = rootEl ?? document.body;
const el = document.createElement("textarea");
el.value = str;
document.body.appendChild(el);
root.appendChild(el);
el.select();
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) => {
const timeout = new Promise((_resolve, reject) => {
setTimeout(() => {
reject(`Timed out in ${ms} ms.`);
reject(new TimeoutError(ms));
}, ms);
});

View File

@ -10,11 +10,13 @@ import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import { fireEvent } from "../../common/dom/fire_event";
import { clamp } from "../../common/number/clamp";
import type { HomeAssistant } from "../../types";
import { debounce } from "../../common/util/debounce";
import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
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")}
</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
? html`<div
class="chart-tooltip ${classMap({
@ -420,7 +432,11 @@ export class HaChartBase extends LitElement {
modifierKey,
speed: 0.05,
},
mode: "x",
mode:
this.chartType !== "timeline" &&
(this.options?.scales?.y as any)?.type === "category"
? "y"
: "x",
onZoomComplete: () => {
const isZoomed = this.chart?.isZoomedOrPanned() ?? false;
if (this._isZoomed && !isZoomed) {
@ -541,6 +557,10 @@ export class HaChartBase extends LitElement {
}
}
private _handleZoomReset() {
this.chart?.resetZoom();
}
static get styles(): CSSResultGroup {
return css`
:host {
@ -552,6 +572,9 @@ export class HaChartBase extends LitElement {
height: 0;
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chart-container {
position: relative;
}
canvas {
max-height: var(--chart-max-height, 400px);
}
@ -670,6 +693,16 @@ export class HaChartBase extends LitElement {
background: 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>`;
}
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`
<div
@ -960,6 +960,13 @@ export class HaDataTable extends LitElement {
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 {
border-top: 1px solid var(--divider-color);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,8 +20,8 @@ export class HatGraphNode extends LitElement {
@property({ attribute: false, reflect: true, type: Boolean }) notEnabled =
false;
@property({ attribute: false, reflect: true, type: Boolean }) graphStart =
false;
@property({ attribute: "graph-start", reflect: true, type: Boolean })
graphStart = 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
);
}
:host([graphStart]) {
:host([graph-start]) {
height: calc(var(--hat-graph-node-size) + 2px);
}
:host([track]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -322,6 +322,13 @@ class HassTabsSubpage extends LitElement {
-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 {
height: calc(100% - 2 * var(--header-height));
height: calc(

View File

@ -614,9 +614,6 @@ export default class HaAutomationActionRow extends LitElement {
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
ha-card {
overflow: hidden;
}
.disabled {
opacity: 0.5;
pointer-events: none;
@ -649,6 +646,8 @@ export default class HaAutomationActionRow extends LitElement {
.disabled-bar {
background: var(--divider-color, #e0e0e0);
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] {

View File

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

View File

@ -504,9 +504,6 @@ export default class HaAutomationConditionRow extends LitElement {
ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
ha-card {
overflow: hidden;
}
.disabled {
opacity: 0.5;
pointer-events: none;
@ -539,6 +536,8 @@ export default class HaAutomationConditionRow extends LitElement {
.disabled-bar {
background: var(--divider-color, #e0e0e0);
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] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
@ -560,6 +559,8 @@ export default class HaAutomationConditionRow extends LitElement {
overflow: hidden;
transition: max-height 0.3s;
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 {
max-height: 100px;

View File

@ -27,6 +27,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import { afterNextRender } from "../../../common/util/render-status";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button-menu";
import "../../../components/ha-fab";
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
if (entityRegPromise) {
const automation = await entityRegPromise;
entityId = automation.entity_id;
try {
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) {
@ -965,9 +995,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
navigate(`/config/automation/edit/${id}`, { replace: true });
}
} catch (errors: any) {
this._errors = errors.body.message || errors.error || errors.body;
this._errors = errors.body?.message || errors.error || errors.body;
showToast(this, {
message: errors.body.message || errors.error || errors.body,
message: errors.body?.message || errors.error || errors.body,
});
throw errors;
} finally {

View File

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

View File

@ -51,6 +51,9 @@ class HaBackupConfigAgents extends LitElement {
private _description(agentId: string) {
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.";
}
if (isNetworkMountAgent(agentId)) {
@ -72,6 +75,10 @@ class HaBackupConfigAgents extends LitElement {
this._agentIds
);
const description = this._description(agentId);
const noCloudSubscription =
agentId === CLOUD_AGENT &&
this.cloudStatus.logged_in &&
!this.cloudStatus.active_subscription;
return html`
<ha-md-list-item>
${isLocalAgent(agentId)
@ -107,7 +114,9 @@ class HaBackupConfigAgents extends LitElement {
<ha-switch
slot="end"
id=${agentId}
.checked=${this._value.includes(agentId)}
.checked=${!noCloudSubscription &&
this._value.includes(agentId)}
.disabled=${noCloudSubscription}
@change=${this._agentToggled}
></ha-switch>
</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
this.value = [...new Set(this.value)]
.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 });
}
@ -144,9 +157,6 @@ class HaBackupConfigAgents extends LitElement {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-list-item img {
width: 48px;
}

View File

@ -138,7 +138,8 @@ class HaBackupConfigData extends LitElement {
const include_addons = data.addons_mode === "custom" ? data.addons : [];
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_all_addons: data.addons_mode === "all",
include_database: data.database,
@ -168,7 +169,7 @@ class HaBackupConfigData extends LitElement {
slot="end"
@change=${this._switchChanged}
.checked=${data.homeassistant}
.disabled=${this.forceHomeAssistant}
.disabled=${this.forceHomeAssistant || data.database}
></ha-switch>
</ha-md-list-item>
@ -296,7 +297,6 @@ class HaBackupConfigData extends LitElement {
...data,
[target.id]: target.checked,
});
fireEvent(this, "value-changed", { value: this.value });
}
private _selectChanged(ev: Event) {
@ -309,7 +309,6 @@ class HaBackupConfigData extends LitElement {
if (target.id === "addons_mode") {
this._showAddons = target.value === "custom";
}
fireEvent(this, "value-changed", { value: this.value });
}
private _addonsChanged(ev: CustomEvent) {
@ -320,7 +319,6 @@ class HaBackupConfigData extends LitElement {
...data,
addons,
});
fireEvent(this, "value-changed", { value: this.value });
}
static styles = css`
@ -332,9 +330,6 @@ class HaBackupConfigData extends LitElement {
ha-md-select {
min-width: 210px;
}
ha-md-list-item {
--md-item-overflow: visible;
}
@media all and (max-width: 450px) {
ha-md-select {
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 { downloadEmergencyKit } from "../../../../../data/backup";
import { showShowBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-show-backup-encryption-key";
@customElement("ha-backup-config-encryption-key")
class HaBackupConfigEncryptionKey extends LitElement {
@ -34,7 +35,13 @@ class HaBackupConfigEncryptionKey extends LitElement {
Download
</ha-button>
</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>
<span slot="headline">Change encryption key</span>
<span slot="supporting-text">
@ -68,6 +75,10 @@ class HaBackupConfigEncryptionKey extends LitElement {
downloadEmergencyKit(this.hass, this._value);
}
private _show() {
showShowBackupEncryptionKeyDialog(this, { currentKey: this._value });
}
private _change() {
showChangeBackupEncryptionKeyDialog(this, {
currentKey: this._value,

View File

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

View File

@ -37,10 +37,7 @@ class HaBackupOverviewBackups extends LitElement {
<p>
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
worst happens, you can get back up and running quickly. It is
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.
worst happens, you can get back up and running quickly.
</p>
</div>
<div class="card-actions">

View File

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

View File

@ -84,22 +84,22 @@ class HaBackupOverviewBackups extends LitElement {
const lastSuccessfulBackup = this._lastSuccessfulBackup(this.backups);
const lastSuccessfulBackupDate = lastSuccessfulBackup
? new Date(lastSuccessfulBackup.date)
: new Date(0);
const lastAttempt = this.config.last_attempted_automatic_backup
? new Date(this.config.last_attempted_automatic_backup)
: undefined;
const lastCompletedBackupDate = this.config.last_completed_automatic_backup
? new Date(this.config.last_completed_automatic_backup)
: undefined;
const now = new Date();
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.";
if (lastAttempt && lastAttempt > lastSuccessfulBackupDate) {
const lastAttemptDescription = `The last automatic backup trigged ${relativeTime(lastAttempt, this.hass.locale, now, true)} wasn't successful.`;
if (lastAttempt && lastAttempt > (lastCompletedBackupDate || 0)) {
const lastAttemptDescription = `The last automatic backup triggered ${relativeTime(lastAttempt, this.hass.locale, now, true)} wasn't successful.`;
return html`
<ha-backup-summary-card
heading="Last automatic backup failed"
@ -119,6 +119,10 @@ class HaBackupOverviewBackups extends LitElement {
`;
}
const nextBackupDescription = this._nextBackupDescription(
this.config.schedule.state
);
if (!lastSuccessfulBackup) {
return html`
<ha-backup-summary-card
@ -126,18 +130,20 @@ class HaBackupOverviewBackups extends LitElement {
description="You have no automatic backups yet."
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>
`;
}
const nextBackupDescription = this._nextBackupDescription(
this.config.schedule.state
);
const numberOfDays = differenceInDays(
// 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),
lastSuccessfulBackupDate
new Date(lastSuccessfulBackup.date)
);
const isOverdue =

View File

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

View File

@ -92,7 +92,9 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
? "Save current encryption key"
: this._step === "new"
? "New encryption key"
: "";
: this._step === "done"
? "Save new encryption key"
: "";
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
@ -166,10 +168,22 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
case "new":
return html`
<p>
Keep this encryption key in a safe place, as you will need it to
access your backup, allowing it to be restored. Either record the
All next backups will use the new encryption key. Encryption keeps
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.
Encryption keeps your backups private and secure.
</p>
<div class="encryption-key">
<p>${this._newEncryptionKey}</p>
@ -189,24 +203,16 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
Download
</ha-button>
</ha-md-list-item>
</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>
`;
</ha-md-list>`;
}
return nothing;
}
private async _copyKeyToClipboard() {
await copyToClipboard(this._newEncryptionKey);
await copyToClipboard(
this._newEncryptionKey,
this.renderRoot.querySelector("div")!
);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
@ -216,7 +222,10 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
if (!this._params?.currentKey) {
return;
}
await copyToClipboard(this._params.currentKey);
await copyToClipboard(
this._params.currentKey,
this.renderRoot.querySelector("div")!
);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
@ -297,13 +306,6 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
p {
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
.map((agent) => agent.agent_id)
.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);
}
@ -200,17 +203,36 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
? html`
<ha-button
@click=${this._submit}
.disabled=${!selectedAgents.length}
.disabled=${this._formData.agents_mode === "custom" &&
!selectedAgents.length}
>
Create backup
</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>
</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() {
if (!this._formData) {
return nothing;

View File

@ -23,7 +23,10 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
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";
type FormData = {
@ -52,8 +55,12 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
@state() private _userPassword?: string;
@state() private _usedUserInput = false;
@state() private _error?: string;
@state() private _state?: RestoreBackupState;
@state() private _stage?: RestoreBackupStage | null;
@state() private _unsub?: Promise<UnsubscribeFunc>;
@ -64,6 +71,11 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._params = params;
this._formData = INITIAL_DATA;
this._userPassword = undefined;
this._usedUserInput = false;
this._error = undefined;
this._state = undefined;
this._stage = undefined;
if (this._params.backup.protected) {
this._backupEncryptionKey = await this._fetchEncryptionKey();
if (!this._backupEncryptionKey) {
@ -85,7 +97,9 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._params = undefined;
this._backupEncryptionKey = undefined;
this._userPassword = undefined;
this._usedUserInput = false;
this._error = undefined;
this._state = undefined;
this._stage = undefined;
this._step = undefined;
this._unsubscribe();
@ -149,15 +163,24 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
}
private _renderEncryption() {
return html`<p>
${this._userPassword
? "The provided encryption key was incorrect, please try again."
: this._backupEncryptionKey
? "The backup is encrypted with a different key or password than that is saved on this system. Please enter the key for this backup."
: "The backup is encrypted. Provide the encryption key to decrypt the backup."}
</p>
return html`${this._usedUserInput
? "The provided encryption key was incorrect, please try again."
: this._backupEncryptionKey
? html`The Backup is encrypted with a different encryption key than
that is saved on this system. Please enter the encryption key for
this backup.<br />
${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
@change=${this._passwordChanged}
@input=${this._passwordChanged}
label="Encryption key"
.value=${this._userPassword || ""}
></ha-password-field>`;
}
@ -186,16 +209,22 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
private async _restoreBackup() {
this._unsubscribe();
this._state = undefined;
this._stage = undefined;
this._error = undefined;
try {
this._step = "progress";
window.addEventListener("connection-status", this._connectionStatus);
this._subscribeBackupEvents();
await this._doRestoreBackup(
this._userPassword || this._backupEncryptionKey
);
} catch (e: any) {
this._unsubscribe();
await this._unsubscribe();
if (e.code === "password_incorrect") {
this._error = undefined;
if (this._userPassword) {
this._usedUserInput = true;
}
this._step = "encryption";
} else {
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() {
this._unsub = subscribeBackupEvents(this.hass!, (event) => {
if (event.manager_state === "idle" && this._state === "in_progress") {
this.closeDialog();
}
if (event.manager_state !== "restore_backup") {
return;
}
this._state = event.state;
if (event.state === "completed") {
this.closeDialog();
}
@ -227,11 +254,12 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
}
private _unsubscribe() {
window.removeEventListener("connection-status", this._connectionStatus);
if (this._unsub) {
this._unsub.then((unsub) => unsub());
const prom = this._unsub.then((unsub) => unsub());
this._unsub = undefined;
return prom;
}
return undefined;
}
private _restoreState() {
@ -306,6 +334,14 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
ha-circular-progress {
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 type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import "../components/ha-backup-agents-picker";
import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
const SUPPORTED_FORMAT = "application/x-tar";
@ -90,6 +89,9 @@ export class DialogUploadBackup
<span slot="title">Upload backup</span>
</ha-dialog-header>
<div slot="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
@ -99,9 +101,6 @@ export class DialogUploadBackup
supports="Supports .tar files"
@file-picked=${this._filePicked}
></ha-file-upload>
${this._error
? html`<ha-alert alertType="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>Cancel</ha-button>
@ -161,6 +160,10 @@ export class DialogUploadBackup
max-width: 500px;
max-height: 100%;
}
ha-alert {
display: block;
margin-bottom: 16px;
}
`,
];
}

View File

@ -5,6 +5,7 @@ export interface BackupOnboardingDialogParams {
submit?: (value: boolean) => void;
cancel?: () => void;
cloudStatus?: CloudStatus;
skipWelcome?: boolean;
}
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`
<hass-tabs-subpage-data-table
hasFab
has-fab
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
name: "My backups",
path: `/config/backup/list`,
},
]}

View File

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

View File

@ -1,14 +1,21 @@
import { mdiDotsVertical, mdiHarddisk } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { debounce } from "../../../common/util/debounce";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list-item";
import "../../../components/ha-alert";
import "../../../components/ha-password-field";
import "../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../data/backup";
import { updateBackupConfig } from "../../../data/backup";
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-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")
class HaConfigBackupSettings extends LitElement {
@ -91,8 +99,30 @@ class HaConfigBackupSettings extends LitElement {
back-path="/config/backup"
.hass=${this.hass}
.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">
<ha-card id="schedule">
<div class="card-header">Automatic backups</div>
@ -134,6 +164,14 @@ class HaConfigBackupSettings extends LitElement {
.cloudStatus=${this.cloudStatus}
@value-changed=${this._agentsConfigChanged}
></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>
</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) {
const value = ev.detail.value as BackupConfigSchedule;
this._config = {

View File

@ -33,7 +33,7 @@ declare global {
class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@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 { isHelperDomain } from "./const";
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
import { fireEvent } from "../../../common/dom/fire_event";
type HelperCreators = {
[domain in HelperDomain]: {
@ -129,6 +130,7 @@ export class DialogHelperDetail extends LitElement {
this._error = undefined;
this._domain = undefined;
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {

View File

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

View File

@ -525,6 +525,9 @@ export class HassioNetwork extends LitElement {
IP_VERSIONS.forEach((version) => {
interfaceOptions[version] = {
method: this._interface![version]?.method || "auto",
nameservers: this._interface![version]?.nameservers?.filter(
(ns: string) => ns.trim()
),
};
if (this._interface![version]?.method === "static") {
interfaceOptions[version] = {
@ -533,9 +536,6 @@ export class HassioNetwork extends LitElement {
(address: string) => address.trim()
),
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>
${this._renderUserFields()}
${!this._deviceTrackersAvailable(this.hass)
${this._deviceTrackersAvailable(this.hass)
? html`
<p>
${this.hass.localize(

View File

@ -26,6 +26,7 @@ import { navigate } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl";
import { afterNextRender } from "../../../common/util/render-status";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button-menu";
import "../../../components/ha-fab";
@ -915,17 +916,49 @@ export class HaScriptEditor extends SubscribeMixin(
// wait for new script to appear in entity registry
if (entityRegPromise) {
const script = await entityRegPromise;
entityId = script.entity_id;
try {
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!, {
categories: {
script: this._entityRegistryUpdate.category || null,
},
labels: this._entityRegistryUpdate.labels || [],
area_id: this._entityRegistryUpdate.area || null,
});
if (entityId) {
await updateEntityRegistryEntry(this.hass, entityId, {
categories: {
script: this._entityRegistryUpdate.category || null,
},
labels: this._entityRegistryUpdate.labels || [],
area_id: this._entityRegistryUpdate.area || null,
});
}
}
this._dirty = false;
@ -934,9 +967,9 @@ export class HaScriptEditor extends SubscribeMixin(
navigate(`/config/script/edit/${id}`, { replace: true });
}
} catch (errors: any) {
this._errors = errors.body.message || errors.error || errors.body;
this._errors = errors.body?.message || errors.error || errors.body;
showToast(this, {
message: errors.body.message || errors.error || errors.body,
message: errors.body?.message || errors.error || errors.body,
});
throw errors;
} finally {

View File

@ -295,9 +295,6 @@ export default class HaScriptFieldRow extends LitElement {
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
ha-card {
overflow: hidden;
}
.disabled {
opacity: 0.5;
pointer-events: none;
@ -330,6 +327,8 @@ export default class HaScriptFieldRow extends LitElement {
.disabled-bar {
background: var(--divider-color, #e0e0e0);
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] {

View File

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

View File

@ -16,7 +16,7 @@ import { getSensorNumericDeviceClasses } from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { LovelaceCard } from "../types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HistoryGraphCardConfig } from "./types";
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);
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
min_rows: (this._config?.entities?.length || 1) * 2,
};
}
public setConfig(config: HistoryGraphCardConfig): void {
if (!config.entities || !Array.isArray(config.entities)) {
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 { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { LovelaceCard } from "../types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { StatisticsGraphCardConfig } from "./types";
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 {
if (!config.entities || !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array");

View File

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

View File

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

View File

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

View File

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

View File

@ -651,6 +651,7 @@
"no_devices": "You don't have any devices",
"no_match": "No matching devices found",
"device": "Device",
"unnamed_device": "Unnamed device",
"no_area": "No area"
},
"category-picker": {
@ -755,7 +756,7 @@
},
"picture-upload": {
"label": "Add picture",
"change_picture": "Change picture",
"clear_picture": "Clear picture",
"current_image_alt": "Current picture",
"supported_formats": "Supports 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_stats": "Source: Long term statistics",
"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": {
"error": "Unable to load map"
@ -2213,7 +2215,7 @@
},
"dialogs": {
"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.",
"note": "This location will be used when you create a backup using the supervisor actions in an automation for example.",
"options": {
@ -3063,6 +3065,12 @@
"unknown_entity": "unknown entity",
"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.",
"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": {
"name": "Triggers",
"header": "When",
@ -5955,7 +5963,7 @@
"bottom right": "Bottom right"
}
},
"transparency": "Background transparency",
"opacity": "Background opacity",
"repeat": {
"name": "Background repeat",
"options": {

View File

@ -1801,6 +1801,15 @@ __metadata:
languageName: node
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":
version: 1.1.0
resolution: "@gulpjs/messages@npm:1.1.0"
@ -5684,6 +5693,13 @@ __metadata:
languageName: node
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":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
@ -6030,7 +6046,7 @@ __metadata:
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
@ -6237,7 +6253,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2":
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@ -7070,7 +7086,7 @@ __metadata:
languageName: node
linkType: hard
"defaults@npm:^1.0.3":
"defaults@npm:^1.0.3, defaults@npm:^1.0.4":
version: 1.0.4
resolution: "defaults@npm:1.0.4"
dependencies:
@ -8180,7 +8196,7 @@ __metadata:
languageName: node
linkType: hard
"fancy-log@npm:2.0.0":
"fancy-log@npm:2.0.0, fancy-log@npm:^2.0.0":
version: 2.0.0
resolution: "fancy-log@npm:2.0.0"
dependencies:
@ -8973,6 +8989,21 @@ __metadata:
languageName: node
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":
version: 5.0.0
resolution: "gulp@npm:5.0.0"
@ -9254,6 +9285,7 @@ __metadata:
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.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"
home-assistant-js-websocket: "npm:9.4.0"
html-minifier-terser: "npm:7.2.0"
@ -12161,6 +12193,15 @@ __metadata:
languageName: node
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":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"
@ -12383,7 +12424,7 @@ __metadata:
languageName: node
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
resolution: "readable-stream@npm:3.6.2"
dependencies:
@ -13566,6 +13607,15 @@ __metadata:
languageName: node
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":
version: 2.21.1
resolution: "streamx@npm:2.21.1"
@ -13993,6 +14043,15 @@ __metadata:
languageName: node
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":
version: 1.1.0
resolution: "thunky@npm:1.1.0"