Compare commits

..

2 Commits

Author SHA1 Message Date
J. Nick Koston
dd00b51f21 Adjust WebSocket ping timeout to 15 seconds
5 seconds was too low to prevent the UI from reloading
when connecting the WebSocket during startup or on
a high latancy connection

This problem presented as the UI reloading over
and over again because it could never respond
to the ping in time on high latancy connections.

At startup it usually only did this once so it
went unnoticed in most cases.

This ping was added in #18934
2025-02-20 11:46:59 -06:00
J. Nick Koston
64b886eea0 Reduce size of address column on Bluetooth Advertisement monitor 2025-01-29 12:51:56 -06:00
185 changed files with 3347 additions and 8321 deletions

View File

@@ -5,15 +5,12 @@
"context": ".."
},
"appPort": "8124:8123",
"postCreateCommand": "./.devcontainer/post_create.sh",
"postCreateCommand": "sudo apt update && sudo apt upgrade -y && sudo apt install -y libpcap-dev",
"postStartCommand": "script/bootstrap",
"containerEnv": {
"DEV_CONTAINER": "1",
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
},
"remoteEnv": {
"NODE_OPTIONS": "--max_old_space_size=8192"
},
"customizations": {
"vscode": {
"extensions": [

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# This script will run after the container is created
# add github cli
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
# Update package lists
sudo apt-get update
sudo apt upgrade -y
# Install necessary packages
sudo apt-get install -y libpcap-dev gh
# Display a message
echo "Post-create script has been executed successfully."

42
.vscode/tasks.json vendored
View File

@@ -1,42 +1,6 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Develop and serve Frontend",
"type": "shell",
"command": "script/develop_and_serve -c ${input:coreUrl}",
// Sync changes here to other tasks until issue resolved
// https://github.com/Microsoft/vscode/issues/61497
"problemMatcher": {
"owner": "ha-build",
"source": "ha-build",
"fileLocation": "absolute",
"severity": "error",
"pattern": [
{
"regexp": "(SyntaxError): (.+): (.+) \\((\\d+):(\\d+)\\)",
"severity": 1,
"file": 2,
"message": 3,
"line": 4,
"column": 5
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "Changes detected. Starting compilation",
"endsPattern": "Build done @"
}
},
"isBackground": true,
"group": {
"kind": "build",
"isDefault": true
},
"runOptions": {
"instanceLimit": 1
}
},
{
"label": "Develop Frontend",
"type": "gulp",
@@ -277,12 +241,6 @@
"id": "supervisorToken",
"type": "promptString",
"description": "The token for the Remote API proxy add-on"
},
{
"id": "coreUrl",
"type": "promptString",
"description": "The URL of the Home Assistant Core instance",
"default": "http://127.0.0.1:8123"
}
]
}

View File

@@ -1,16 +1,16 @@
// @ts-check
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",
export default [
...rootConfig,
{
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",
},
},
});
];

View File

@@ -90,14 +90,6 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/leaflet.css"),
staticPath("images/leaflet/")
);
copyFileDir(
npmPath("leaflet.markercluster/dist/MarkerCluster.css"),
staticPath("images/leaflet/")
);
copyFileDir(
npmPath("leaflet.markercluster/dist/MarkerCluster.Default.css"),
staticPath("images/leaflet/")
);
fs.copySync(
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")

View File

@@ -65,7 +65,6 @@ export class HaDemo extends HomeAssistantAppEl {
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal",
area_id: null,
disabled_by: null,
@@ -86,7 +85,6 @@ export class HaDemo extends HomeAssistantAppEl {
},
{
config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal",
area_id: null,
disabled_by: null,

View File

@@ -11,7 +11,6 @@ export const mockConfigEntries = (hass: MockHomeAssistant) => {
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,

View File

@@ -1,16 +1,11 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -20,14 +15,17 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
export default tseslint.config(
...compat.extends("airbnb-base", "plugin:lit-a11y/recommended"),
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
wcConfigs["flat/recommended"],
export default [
...compat.extends(
"airbnb-base",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/strict",
"plugin:@typescript-eslint/stylistic",
"plugin:wc/recommended",
"plugin:lit/all",
"plugin:lit-a11y/recommended",
"prettier"
),
{
plugins: {
"unused-imports": unusedImports,
@@ -45,7 +43,7 @@ export default tseslint.config(
Polymer: true,
},
parser: tseslint.parser,
parser: tsParser,
ecmaVersion: 2020,
sourceType: "module",
@@ -186,5 +184,5 @@ export default tseslint.config(
],
"no-use-before-define": "off",
},
}
);
},
];

View File

@@ -1,10 +1,10 @@
// @ts-check
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
export default [
...rootConfig,
{
rules: {
"no-console": "off",
},
},
});
];

View File

@@ -48,7 +48,6 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -72,7 +71,6 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -96,7 +94,6 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null,
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,

View File

@@ -47,7 +47,6 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -71,7 +70,6 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@@ -95,7 +93,6 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null,
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,

View File

@@ -32,8 +32,6 @@ const createConfigEntry = (
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
disabled_by: null,
pref_disable_new_entities: false,
pref_disable_polling: false,
@@ -190,7 +188,6 @@ const createEntityRegistryEntries = (
): EntityRegistryEntry[] => [
{
config_entry_id: item.entry_id,
config_subentry_id: null,
device_id: "mock-device-id",
area_id: null,
disabled_by: null,
@@ -217,7 +214,6 @@ const createDeviceRegistryEntries = (
{
entry_type: null,
config_entries: [item.entry_id],
config_entries_subentries: {},
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",

View File

@@ -1,6 +1,8 @@
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { stripDiacritics } from "../../../src/common/string/strip-diacritics";
import type { StoreAddon } from "../../../src/data/supervisor/store";
import { getStripDiacriticsFn } from "../../../src/util/fuse";
export function filterAndSort(addons: StoreAddon[], filter: string) {
const options: IFuseOptions<StoreAddon> = {
@@ -8,8 +10,8 @@ export function filterAndSort(addons: StoreAddon[], filter: string) {
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: getStripDiacriticsFn,
};
const fuse = new Fuse(addons, options);
return fuse.search(filter).map((result) => result.item);
return fuse.search(stripDiacritics(filter)).map((result) => result.item);
}

View File

@@ -14,7 +14,7 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
declare global {
interface HASSDomEvents {
"hassio-backup-uploaded": { backup: HassioBackup };
"backup-uploaded": { backup: HassioBackup };
"backup-cleared": undefined;
}
}
@@ -70,7 +70,7 @@ export class HassioUploadBackup extends LitElement {
this._uploading = true;
try {
const backup = await uploadBackup(this.hass, file);
fireEvent(this, "hassio-backup-uploaded", { backup: backup.data });
fireEvent(this, "backup-uploaded", { backup: backup.data });
} catch (err: any) {
showAlertDialog(this, {
title: "Upload failed",

View File

@@ -5,6 +5,7 @@ import { customElement, property, query } from "lit/decorators";
import { atLeastVersion } from "../../../src/common/config/version";
import { formatDate } from "../../../src/common/datetime/format_date";
import { formatDateTime } from "../../../src/common/datetime/format_date_time";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import "../../../src/components/ha-checkbox";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-textfield";
@@ -18,10 +19,13 @@ import type {
} from "../../../src/data/hassio/backup";
import type { Supervisor } from "../../../src/data/supervisor/supervisor";
import { mdiHomeAssistant } from "../../../src/resources/home-assistant-logo-svg";
import type { HomeAssistant } from "../../../src/types";
import type { HomeAssistant, TranslationDict } from "../../../src/types";
import "./supervisor-formfield-label";
import type { HaTextField } from "../../../src/components/ha-textfield";
type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];
interface CheckboxItem {
slug: string;
checked: boolean;
@@ -63,6 +67,8 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
export class SupervisorBackupContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ attribute: false }) public supervisor?: Supervisor;
@property({ attribute: false }) public backup?: HassioBackupDetail;
@@ -109,6 +115,10 @@ export class SupervisorBackupContent extends LitElement {
this._focusTarget?.focus();
}
private _localize = (key: BackupOrRestoreKey) =>
this.supervisor?.localize(`backup.${key}`) ||
this.localize!(`ui.panel.page-onboarding.restore.${key}`);
protected render() {
if (!this.onboarding && !this.supervisor) {
return nothing;
@@ -122,8 +132,8 @@ export class SupervisorBackupContent extends LitElement {
${this.backup
? html`<div class="details">
${this.backup.type === "full"
? this.supervisor?.localize("backup.full_backup")
: this.supervisor?.localize("backup.partial_backup")}
? this._localize("full_backup")
: this._localize("partial_backup")}
(${Math.ceil(this.backup.size * 10) / 10 + " MB"})<br />
${this.hass
? formatDateTime(
@@ -135,7 +145,7 @@ export class SupervisorBackupContent extends LitElement {
</div>`
: html`<ha-textfield
name="backupName"
.label=${this.supervisor?.localize("backup.name")}
.label=${this._localize("name")}
.value=${this.backupName}
@change=${this._handleTextValueChanged}
>
@@ -143,13 +153,11 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup || this.backup.type === "full"
? html`<div class="sub-header">
${!this.backup
? this.supervisor?.localize("backup.type")
: this.supervisor?.localize("backup.select_type")}
? this._localize("type")
: this._localize("select_type")}
</div>
<div class="backup-types">
<ha-formfield
.label=${this.supervisor?.localize("backup.full_backup")}
>
<ha-formfield .label=${this._localize("full_backup")}>
<ha-radio
@change=${this._handleRadioValueChanged}
value="full"
@@ -158,9 +166,7 @@ export class SupervisorBackupContent extends LitElement {
>
</ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.supervisor?.localize("backup.partial_backup")}
>
<ha-formfield .label=${this._localize("partial_backup")}>
<ha-radio
@change=${this._handleRadioValueChanged}
value="partial"
@@ -196,7 +202,7 @@ export class SupervisorBackupContent extends LitElement {
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this.supervisor?.localize("backup.folders")}
.label=${this._localize("folders")}
.iconPath=${mdiFolder}
>
</supervisor-formfield-label>`}
@@ -216,7 +222,7 @@ export class SupervisorBackupContent extends LitElement {
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this.supervisor?.localize("backup.addons")}
.label=${this._localize("addons")}
.iconPath=${mdiPuzzle}
>
</supervisor-formfield-label>`}
@@ -241,7 +247,7 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup
? html`<ha-formfield
class="password"
.label=${this.supervisor?.localize("backup.password_protection")}
.label=${this._localize("password_protection")}
>
<ha-checkbox
.checked=${this.backupHasPassword}
@@ -253,7 +259,7 @@ export class SupervisorBackupContent extends LitElement {
${this.backupHasPassword
? html`
<ha-password-field
.label=${this.supervisor?.localize("backup.password")}
.label=${this._localize("password")}
name="backupPassword"
.value=${this.backupPassword}
@change=${this._handleTextValueChanged}
@@ -261,7 +267,7 @@ export class SupervisorBackupContent extends LitElement {
</ha-password-field>
${!this.backup
? html`<ha-password-field
.label=${this.supervisor?.localize("backup.confirm_password")}
.label=${this._localize("confirm_password")}
name="confirmBackupPassword"
.value=${this.confirmBackupPassword}
@change=${this._handleTextValueChanged}

View File

@@ -72,7 +72,7 @@ export class DialogHassioBackupUpload
</ha-header-bar>
</div>
<hassio-upload-backup
@hassio-backup-uploaded=${this._backupUploaded}
@backup-uploaded=${this._backupUploaded}
.hass=${this.hass}
></hassio-upload-backup>
</ha-dialog>

View File

@@ -35,6 +35,7 @@ import { fileDownload } from "../../../../src/util/file_download";
import "../../components/supervisor-backup-content";
import type { SupervisorBackupContent } from "../../components/supervisor-backup-content";
import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup";
import type { BackupOrRestoreKey } from "../../util/translations";
import type { HaMdDialog } from "../../../../src/components/ha-md-dialog";
@customElement("dialog-hassio-backup")
@@ -42,7 +43,7 @@ class HassioBackupDialog
extends LitElement
implements HassDialog<HassioBackupDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _error?: string;
@@ -61,13 +62,9 @@ class HassioBackupDialog
this._dialogParams = dialogParams;
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
if (!this._backup) {
this._error = this._dialogParams.supervisor?.localize(
"backup.no_backup_found"
);
this._error = this._localize("no_backup_found");
} else if (this._dialogParams.onboarding && !this._backup.homeassistant) {
this._error = this._dialogParams.supervisor?.localize(
"backup.restore_no_home_assistant"
);
this._error = this._localize("restore_no_home_assistant");
}
this._restoringBackup = false;
}
@@ -85,6 +82,13 @@ class HassioBackupDialog
return true;
}
private _localize(key: BackupOrRestoreKey) {
return (
this._dialogParams!.supervisor?.localize(`backup.${key}`) ||
this._dialogParams!.localize!(`ui.panel.page-onboarding.restore.${key}`)
);
}
protected render() {
if (!this._dialogParams || !this._backup) {
return nothing;
@@ -98,7 +102,7 @@ class HassioBackupDialog
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this._dialogParams.supervisor?.localize("backup.close")}
.label=${this._localize("close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._restoringBackup}
@@ -146,6 +150,7 @@ class HassioBackupDialog
.supervisor=${this._dialogParams.supervisor}
.backup=${this._backup}
.onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
dialogInitialFocus
>
</supervisor-backup-content>
@@ -156,7 +161,7 @@ class HassioBackupDialog
.disabled=${this._restoringBackup || !!this._error}
@click=${this._restoreClicked}
>
${this._dialogParams.supervisor?.localize("backup.restore")}
${this._localize("restore")}
</ha-button>
</div>
</ha-md-dialog>
@@ -191,22 +196,18 @@ class HassioBackupDialog
}
if (
!(await showConfirmationDialog(this, {
title: supervisor?.localize(
`backup.${
this._backup!.type === "full"
? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title"
}`
title: this._localize(
this._backup!.type === "full"
? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title"
),
text: supervisor?.localize(
`backup.${
this._backup!.type === "full"
? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text"
}`
text: this._localize(
this._backup!.type === "full"
? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text"
),
confirmText: supervisor?.localize("backup.restore"),
dismissText: supervisor?.localize("backup.cancel"),
confirmText: this._localize("restore"),
dismissText: this._localize("cancel"),
}))
) {
this._restoringBackup = false;
@@ -226,8 +227,7 @@ class HassioBackupDialog
this.closeDialog();
} catch (error: any) {
this._error =
error?.body?.message ||
supervisor?.localize("backup.restore_start_failed");
error?.body?.message || this._localize("restore_start_failed");
} finally {
this._restoringBackup = false;
}
@@ -286,7 +286,7 @@ class HassioBackupDialog
title: supervisor.localize("backup.remote_download_title"),
text: supervisor.localize("backup.remote_download_text"),
confirmText: supervisor.localize("backup.download"),
dismissText: supervisor?.localize("backup.cancel"),
dismissText: this._localize("cancel"),
});
if (!confirm) {
return;
@@ -302,7 +302,7 @@ class HassioBackupDialog
private get _computeName() {
return this._backup
? this._backup.name || this._backup.slug
: this._dialogParams!.supervisor?.localize("backup.unnamed_backup") || "";
: this._localize("unnamed_backup");
}
static get styles(): CSSResultGroup {

View File

@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../src/common/dom/fire_event";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import type { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioBackupDialogParams {
@@ -7,6 +8,7 @@ export interface HassioBackupDialogParams {
onRestoring?: () => void;
onboarding?: boolean;
supervisor?: Supervisor;
localize?: LocalizeFunc;
}
export const showHassioBackupDialog = (

View File

@@ -0,0 +1,4 @@
import type { TranslationDict } from "../../../src/types";
export type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];

View File

@@ -26,25 +26,25 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.26.9",
"@babel/runtime": "7.26.7",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/autocomplete": "6.18.4",
"@codemirror/commands": "6.8.0",
"@codemirror/language": "6.10.8",
"@codemirror/legacy-modes": "6.4.3",
"@codemirror/search": "6.5.9",
"@codemirror/state": "6.5.2",
"@codemirror/legacy-modes": "6.4.2",
"@codemirror/search": "6.5.8",
"@codemirror/state": "6.5.1",
"@codemirror/view": "6.36.2",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.17.3",
"@formatjs/intl-displaynames": "6.8.10",
"@formatjs/intl-durationformat": "0.7.3",
"@formatjs/intl-datetimeformat": "6.17.2",
"@formatjs/intl-displaynames": "6.8.9",
"@formatjs/intl-durationformat": "0.7.2",
"@formatjs/intl-getcanonicallocales": "2.5.4",
"@formatjs/intl-listformat": "7.7.10",
"@formatjs/intl-locale": "4.2.10",
"@formatjs/intl-numberformat": "8.15.3",
"@formatjs/intl-pluralrules": "5.4.3",
"@formatjs/intl-relativetimeformat": "11.4.10",
"@formatjs/intl-listformat": "7.7.9",
"@formatjs/intl-locale": "4.2.9",
"@formatjs/intl-numberformat": "8.15.2",
"@formatjs/intl-pluralrules": "5.4.2",
"@formatjs/intl-relativetimeformat": "11.4.9",
"@fullcalendar/core": "6.1.15",
"@fullcalendar/daygrid": "6.1.15",
"@fullcalendar/interaction": "6.1.15",
@@ -53,9 +53,9 @@
"@fullcalendar/timegrid": "6.1.15",
"@lezer/highlight": "1.2.1",
"@lit-labs/context": "0.4.1",
"@lit-labs/motion": "1.0.8",
"@lit-labs/observers": "2.0.5",
"@lit-labs/virtualizer": "2.1.0",
"@lit-labs/motion": "1.0.7",
"@lit-labs/observers": "2.0.4",
"@lit-labs/virtualizer": "2.0.15",
"@lrnwebcomponents/simple-tooltip": "8.0.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
@@ -91,14 +91,14 @@
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.5",
"@vaadin/vaadin-themable-mixin": "24.6.5",
"@vaadin/combo-box": "24.6.2",
"@vaadin/vaadin-themable-mixin": "24.6.2",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.9",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "3.0.0",
"barcode-detector": "2.3.1",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.40.0",
@@ -110,24 +110,22 @@
"dialog-polyfill": "0.5.6",
"echarts": "5.6.0",
"element-internals-polyfill": "1.3.13",
"fuse.js": "7.1.0",
"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",
"intl-messageformat": "10.7.15",
"intl-messageformat": "10.7.14",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "2.8.0",
"lit-html": "2.8.0",
"luxon": "3.5.0",
"marked": "15.0.7",
"marked": "15.0.6",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
"qrcode": "1.5.4",
@@ -139,7 +137,7 @@
"tinykeys": "3.0.0",
"tsparticles-engine": "2.12.0",
"tsparticles-preset-links": "2.12.0",
"ua-parser-js": "2.0.2",
"ua-parser-js": "2.0.0",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
@@ -154,22 +152,22 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.26.9",
"@babel/core": "7.26.7",
"@babel/helper-define-polyfill-provider": "0.6.3",
"@babel/plugin-proposal-decorators": "7.25.9",
"@babel/plugin-transform-runtime": "7.26.9",
"@babel/preset-env": "7.26.9",
"@babel/plugin-transform-runtime": "7.25.9",
"@babel/preset-env": "7.26.7",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2",
"@lokalise/node-api": "13.1.0",
"@octokit/auth-oauth-device": "7.1.3",
"@octokit/plugin-retry": "7.1.4",
"@octokit/rest": "21.1.1",
"@lokalise/node-api": "13.0.0",
"@octokit/auth-oauth-device": "7.1.2",
"@octokit/plugin-retry": "7.1.3",
"@octokit/rest": "21.1.0",
"@rsdoctor/rspack-plugin": "0.4.13",
"@rspack/cli": "1.2.3",
"@rspack/core": "1.2.3",
"@rspack/cli": "1.2.2",
"@rspack/core": "1.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-receiver": "6.0.20",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
@@ -177,7 +175,6 @@
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.16",
"@types/leaflet-draw": "1.0.11",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.4.2",
"@types/mocha": "10.0.10",
@@ -186,12 +183,14 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.0.5",
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@vitest/coverage-v8": "3.0.4",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.20.1",
"eslint": "9.19.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.1",
"eslint-import-resolver-webpack": "0.13.10",
@@ -216,17 +215,16 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"object-hash": "3.0.0",
"pinst": "3.0.0",
"prettier": "3.5.1",
"prettier": "3.4.2",
"rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "19.0.2",
"tar": "7.4.3",
"terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.1",
"vitest": "3.0.5",
"vitest": "3.0.4",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -240,7 +238,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.15.0",
"globals": "15.14.0",
"tslib": "2.8.1"
},
"packageManager": "yarn@4.6.0"

View File

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

View File

@@ -64,7 +64,7 @@ echo Core is used from ${coreUrl}
HASS_URL="$coreUrl" ./script/develop &
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
yarn dlx serve -l $frontendPort ./hass_frontend -s &
# keep the script running while serving
wait

View File

@@ -1,3 +0,0 @@
{
"cleanUrls": false
}

View File

@@ -1,4 +1,3 @@
import memoizeOne from "memoize-one";
import { theme2hex } from "./convert-color";
export const COLORS = [
@@ -75,12 +74,3 @@ export function getGraphColorByIndex(
getColorByIndex(index);
return theme2hex(themeColor);
}
export const getAllGraphColors = memoizeOne(
(style: CSSStyleDeclaration) =>
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
// this is not ideal, but we need to memoize the colors
newArgs[0].getPropertyValue("--graph-color-1") ===
lastArgs[0].getPropertyValue("--graph-color-1")
);

View File

@@ -136,18 +136,11 @@ export function theme2hex(themeColor: string): string {
}
const rgbFromColorName = colors[themeColor];
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
if (!rgbFromColorName) {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
}
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
}
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
return rgb2hex(rgbFromColorName);
}

View File

@@ -26,20 +26,6 @@ const formatDateTimeMem = memoizeOne(
})
);
export const formatDateTimeWithBrowserDefaults = (dateObj: Date) =>
formatDateTimeWithBrowserDefaultsMem().format(dateObj);
const formatDateTimeWithBrowserDefaultsMem = memoizeOne(
() =>
new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
);
// Aug 9, 2021, 8:23 AM
export const formatShortDateTimeWithYear = (
dateObj: Date,

View File

@@ -16,30 +16,11 @@ export const setupLeafletMap = async (
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(style);
const markerClusterStyle = document.createElement("link");
markerClusterStyle.setAttribute(
"href",
"/static/images/leaflet/MarkerCluster.css"
);
markerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(markerClusterStyle);
const defaultMarkerClusterStyle = document.createElement("link");
defaultMarkerClusterStyle.setAttribute(
"href",
"/static/images/leaflet/MarkerCluster.Default.css"
);
defaultMarkerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(defaultMarkerClusterStyle);
map.setView([52.3731339, 4.8903147], 13);
const tileLayer = createTileLayer(Leaflet).addTo(map);

View File

@@ -1,9 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { UNAVAILABLE_STATES } from "../../data/entity";
import type { HomeAssistant } from "../../types";
import { computeDomain } from "./compute_domain";
import { stringCompare } from "../string/compare";
export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [
@@ -240,7 +237,6 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
};
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
): string[] => {
@@ -273,19 +269,7 @@ export const getStates = (
case "device_tracker":
case "person":
if (!attribute) {
result.push(
...Object.entries(hass.states)
.filter(
([entityId, stateObj]) =>
computeDomain(entityId) === "zone" &&
entityId !== "zone.home" &&
stateObj.attributes.friendly_name
)
.map(([_entityId, stateObj]) => stateObj.attributes.friendly_name!)
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
);
result.push("home", "not_home");
}
break;
case "event":

View File

@@ -1,32 +0,0 @@
import type { LatLngExpression, Layer, Map, MarkerOptions } from "leaflet";
import { Marker } from "leaflet";
export class DecoratedMarker extends Marker {
decorationLayer: Layer | undefined;
constructor(
latlng: LatLngExpression,
decorationLayer?: Layer,
options?: MarkerOptions
) {
super(latlng, options);
this.decorationLayer = decorationLayer;
}
onAdd(map: Map) {
super.onAdd(map);
// If decoration has been provided, add it to the map as well
this.decorationLayer?.addTo(map);
return this;
}
onRemove(map: Map) {
// If decoration has been provided, remove it from the map as well
this.decorationLayer?.remove();
return super.onRemove(map);
}
}

View File

@@ -41,7 +41,7 @@ export class HaProgressButton extends LitElement {
indeterminate
></ha-circular-progress>
`
: nothing}
: ""}
</div>
`}
`;
@@ -117,9 +117,6 @@ export class HaProgressButton extends LitElement {
mwc-button.error slot {
visibility: hidden;
}
:host([destructive]) {
--mdc-theme-primary: var(--error-color);
}
`;
}

View File

@@ -1,4 +1,5 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
@@ -6,46 +7,56 @@ import {
formatDateVeryShort,
formatDateWeekdayShort,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import { formatTime } from "../../common/datetime/format_time";
export function formatTimeLabel(
value: number | Date,
export function getLabelFormatter(
locale: FrontendLocaleData,
config: HassConfig,
minutesDifference: number
dayDifference = 0
) {
const dayDifference = minutesDifference / 60 / 24;
const date = new Date(value);
if (dayDifference > 88) {
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
}
if (dayDifference > 35) {
return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config);
}
if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label;
}
if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config);
}
if (minutesDifference && minutesDifference < 5) {
return formatTimeWithSeconds(date, locale, config);
}
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return (value: number | Date) => {
const date = new Date(value);
if (dayDifference > 88) {
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
}
if (dayDifference > 35) {
return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config);
}
if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label;
}
if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config);
}
// show only date for the beginning of the day
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
}

View File

@@ -1,30 +1,25 @@
import { consume } from "@lit-labs/context";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type {
ECElementEvent,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { getAllGraphColors } from "../../common/color/colors";
import { consume } from "@lit-labs/context";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array";
import type { ECOption } from "../../resources/echarts";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { Themes } from "../../data/ws-themes";
import { themesContext } from "../../data/context";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@@ -49,10 +44,6 @@ export class HaChartBase extends LitElement {
@state() private _isZoomed = false;
@state() private _zoomRatio = 1;
@state() private _minutesDifference = 24 * 60;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -68,16 +59,12 @@ export class HaChartBase extends LitElement {
private _listeners: (() => void)[] = [];
private _originalZrFlush?: () => void;
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
}
public connectedCallback() {
@@ -88,19 +75,19 @@ export class HaChartBase extends LitElement {
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
this._setChartOptions({ animation: !this._reducedMotion });
}
this._reducedMotion = matches;
this.chart?.setOption({ animation: !this._reducedMotion });
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
if ((isMac && ev.metaKey) || (!isMac && ev.ctrlKey)) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
@@ -109,7 +96,9 @@ export class HaChartBase extends LitElement {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
@@ -127,37 +116,47 @@ export class HaChartBase extends LitElement {
}
public willUpdate(changedProps: PropertyValues): void {
if (!this.chart) {
super.willUpdate(changedProps);
if (!this.hasUpdated || !this.chart) {
return;
}
if (changedProps.has("_themes")) {
this._setupChart();
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("data")) {
chartOptions.series = this.data;
this.chart.setOption(
{ series: this.data },
{ lazyUpdate: true, replaceMerge: ["series"] }
);
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (changedProps.has("options") || changedProps.has("_isZoomed")) {
this.chart.setOption(this._createOptions(), {
lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom
replaceMerge: [
"xAxis",
"yAxis",
"dataZoom",
"dataset",
"tooltip",
"legend",
"grid",
"visualMap",
],
});
}
}
protected render() {
return html`
<div
class=${classMap({
"chart-container": true,
"has-legend": !!this.options?.legend,
})}
class="chart-container"
style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`,
})}
@wheel=${this._handleWheel}
>
<div class="chart"></div>
${this._isZoomed
@@ -174,14 +173,6 @@ export class HaChartBase extends LitElement {
`;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
this.hass.locale,
this.hass.config,
this._minutesDifference * this._zoomRatio
);
private async _setupChart() {
if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
@@ -192,9 +183,10 @@ export class HaChartBase extends LitElement {
}
const echarts = (await import("../../resources/echarts")).default;
echarts.registerTheme("custom", this._createTheme());
this.chart = echarts.init(container, "custom");
this.chart = echarts.init(
container,
this._themes.darkMode ? "dark" : "light"
);
this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) {
const isSelected = params.selected[params.name];
@@ -208,7 +200,6 @@ export class HaChartBase extends LitElement {
this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100;
this._zoomRatio = (end - start) / 100;
});
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
@@ -239,67 +230,31 @@ export class HaChartBase extends LitElement {
type: "inside",
orient: "horizontal",
filterMode: "none",
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
moveOnMouseMove: this._isZoomed,
preventDefaultMouseMove: this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,
};
}
private _createOptions(): ECOption {
let xAxis = this.options?.xAxis;
if (xAxis) {
xAxis = Array.isArray(xAxis) ? xAxis : [xAxis];
xAxis = xAxis.map((axis: XAXisOption) => {
if (axis.type !== "time" || axis.show === false) {
return axis;
}
if (axis.max && axis.min) {
this._minutesDifference = differenceInMinutes(
axis.max as Date,
axis.min as Date
);
}
const dayDifference = this._minutesDifference / 60 / 24;
let minInterval: number | undefined;
if (dayDifference) {
minInterval =
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined;
}
return {
axisLine: {
show: false,
},
splitLine: {
show: true,
},
...axis,
axisLabel: {
formatter: this._formatTimeLabel,
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
...axis.axisLabel,
},
minInterval,
} as XAXisOption;
});
}
const darkMode = this._themes.darkMode ?? false;
const options = {
backgroundColor: "transparent",
animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false,
darkMode,
aria: {
show: true,
},
dataZoom: this._getDataZoomConfig(),
...this.options,
xAxis,
legend: this.options?.legend
? {
// we should create our own theme but this is a quick fix for now
inactiveColor: darkMode ? "#444" : "#ccc",
...this.options.legend,
}
: undefined,
};
const isMobile = window.matchMedia(
@@ -313,242 +268,44 @@ export class HaChartBase extends LitElement {
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
return options;
}
private _createTheme() {
const style = getComputedStyle(this);
return {
color: getAllGraphColors(style),
backgroundColor: "transparent",
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontFamily: "Roboto, Noto, sans-serif",
},
title: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
subtextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
line: {
lineStyle: {
width: 1.5,
},
symbolSize: 1,
symbol: "circle",
smooth: false,
},
bar: {
itemStyle: {
barBorderWidth: 1.5,
},
},
categoryAxis: {
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: false,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
valueAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
logAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
timeAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
legend: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
inactiveColor: style.getPropertyValue("--disabled-text-color"),
pageIconColor: style.getPropertyValue("--primary-text-color"),
pageIconInactiveColor: style.getPropertyValue("--disabled-text-color"),
pageTextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
tooltip: {
backgroundColor: style.getPropertyValue("--card-background-color"),
borderColor: style.getPropertyValue("--divider-color"),
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontSize: 12,
},
axisPointer: {
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
crossStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
},
timeline: {},
};
}
private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 200);
}
private _setChartOptions(options: ECOption) {
if (!this.chart) {
return;
}
if (!this._originalZrFlush) {
const dataSize = ensureArray(this.data).reduce(
(acc, series) => acc + (series.data as any[]).length,
0
);
if (dataSize > 10000) {
// for large datasets zr.flush takes 30-40% of the render time
// so we delay it a bit to avoid blocking the main thread
const zr = this.chart.getZr();
this._originalZrFlush = zr.flush.bind(zr);
zr.flush = () => {
setTimeout(() => {
this._originalZrFlush?.();
}, 10);
};
}
}
const replaceMerge = options.series ? ["series"] : [];
this.chart.setOption(options, { replaceMerge });
return Math.max(this.clientWidth / 2, 400);
}
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
private _handleWheel(e: WheelEvent) {
// if the window is not focused, we don't receive the keydown events but scroll still works
if (!this.options?.dataZoom) {
const modifierPressed = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
if (modifierPressed) {
e.preventDefault();
}
if (modifierPressed !== this._modifierPressed) {
this._modifierPressed = modifierPressed;
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
}
static styles = css`
:host {
display: block;
position: relative;
letter-spacing: normal;
}
.chart-container {
position: relative;
max-height: var(--chart-max-height, 350px);
max-height: var(--chart-max-height, 400px);
}
.chart {
width: 100%;
@@ -564,9 +321,6 @@ export class HaChartBase extends LitElement {
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
.has-legend .zoom-reset {
top: 64px;
}
`;
}

View File

@@ -4,6 +4,7 @@ import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -17,10 +18,10 @@ import {
getNumberFormatOptions,
formatNumber,
} from "../../common/number/format_number";
import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -71,12 +72,8 @@ export class StateHistoryChartLine extends LitElement {
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25;
@state() private _visualMap?: VisualMapComponentOption[];
private _chartTime: Date = new Date();
protected render() {
@@ -87,104 +84,49 @@ export class StateHistoryChartLine extends LitElement {
.options=${this._chartOptions}
.height=${this.height}
style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
`;
}
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
this._hiddenStats.has(dataset.name as string)
)
return;
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
if (param) {
datapoints.push(param);
return;
}
// If the datapoint is not found, we need to find the last datapoint before the current time
let lastData: any;
const data = dataset.data || [];
for (let i = data.length - 1; i >= 0; i--) {
const point = data[i];
if (point && point[0] <= time && point[1]) {
lastData = point;
break;
private _renderTooltip(params) {
return params
.map((param, index: number) => {
let value = `${formatNumber(
param.value[1] as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[param.seriesIndex]]
)
)} ${this.unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
}
if (!lastData) return;
datapoints.push({
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${param.seriesName}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
};
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
}
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.name);
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
}
public willUpdate(changedProps: PropertyValues) {
@@ -210,48 +152,53 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("_chartData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_visualMap") ||
changedProps.has("_yWidth")
) {
const dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
const splitLineStyle = this.hass.themes?.darkMode
? { opacity: 0.15 }
: {};
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
min: this.fitYData ? this.minYAxis : undefined,
max: this.fitYData ? this.maxYAxis : undefined,
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
nameTextStyle: {
align: "left",
},
axisLine: {
show: false,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
axisLabel: {
margin: 5,
@@ -271,8 +218,6 @@ export class StateHistoryChartLine extends LitElement {
} as YAXisOption,
legend: {
show: this.showNames,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
},
@@ -282,11 +227,37 @@ export class StateHistoryChartLine extends LitElement {
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
bottom: 30,
},
visualMap: this._visualMap,
visualMap: this._chartData
.map((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return false;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
return {
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
};
})
.filter(Boolean) as VisualMapComponentOption[],
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip,
formatter: this._renderTooltip.bind(this),
},
};
}
@@ -336,18 +307,13 @@ export class StateHistoryChartLine extends LitElement {
prevValues = datavalues;
};
const addDataSet = (
id: string,
nameY: string,
color?: string,
fill = false
) => {
const addDataSet = (nameY: string, color?: string, fill = false) => {
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
id,
id: nameY,
data: [],
type: "line",
cursor: "default",
@@ -355,7 +321,6 @@ export class StateHistoryChartLine extends LitElement {
color,
symbol: "circle",
step: "end",
animationDurationUpdate: 0,
symbolSize: 1,
lineStyle: {
width: fill ? 0 : 1.5,
@@ -410,23 +375,13 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
addDataSet(
states.entity_id + "-current_temperature",
this.showNames
? this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
`${this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})}`
);
if (hasHeat) {
addDataSet(
states.entity_id + "-heating",
this.showNames
? this.hass.localize("ui.card.climate.heating", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
),
`${this.hass.localize("ui.card.climate.heating", { name: name })}`,
computedStyles.getPropertyValue("--state-climate-heat-color"),
true
);
@@ -435,12 +390,7 @@ export class StateHistoryChartLine extends LitElement {
}
if (hasCool) {
addDataSet(
states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`,
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
@@ -450,40 +400,22 @@ export class StateHistoryChartLine extends LitElement {
if (hasTargetRange) {
addDataSet(
states.entity_id + "-target_temperature_mode",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})}`
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})}`
);
} else {
addDataSet(
states.entity_id + "-target_temperature",
this.showNames
? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name,
}
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
`${this.hass.localize("ui.card.climate.target_temperature_entity", {
name: name,
})}`
);
}
@@ -536,29 +468,19 @@ export class StateHistoryChartLine extends LitElement {
);
addDataSet(
states.entity_id + "-target_humidity",
this.showNames
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})}`
);
if (hasCurrent) {
addDataSet(
states.entity_id + "-current_humidity",
this.showNames
? this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
`${this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)}`
);
}
@@ -566,40 +488,25 @@ export class StateHistoryChartLine extends LitElement {
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
states.entity_id + "-humidifying",
this.showNames
? this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
`${this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})}`,
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
states.entity_id + "-drying",
this.showNames
? this.hass.localize("ui.card.humidifier.drying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
`${this.hass.localize("ui.card.humidifier.drying", {
name: name,
})}`,
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
states.entity_id + "-on",
this.showNames
? this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
`${this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})}`,
undefined,
true
);
@@ -632,7 +539,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name);
addDataSet(name);
let lastValue: number;
let lastDate: Date;
@@ -701,46 +608,6 @@ export class StateHistoryChartLine extends LitElement {
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
const visualMap: VisualMapComponentOption[] = [];
this._chartData.forEach((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
}
customElements.define("state-history-chart-line", StateHistoryChartLine);

View File

@@ -8,6 +8,7 @@ import type {
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -21,6 +22,7 @@ import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -65,7 +67,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
.data=${this._chartData}
@chart-click=${this._handleChartClick}
></ha-chart-base>
`;
@@ -127,12 +129,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName } = Array.isArray(params)
const { value, name, marker } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</h4>`;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
@@ -183,12 +183,13 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const maxInternalLabelWidth = narrow ? 70 : 165;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = {
xAxis: {
type: "time",
@@ -196,10 +197,21 @@ export class StateHistoryChartTimeline extends LitElement {
max: this.endTime,
axisTick: {
show: true,
lineStyle: {
opacity: 0.4,
},
},
splitLine: {
show: false,
},
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "category",
@@ -214,18 +226,14 @@ export class StateHistoryChartTimeline extends LitElement {
},
axisLabel: {
show: showNames,
width: labelWidth,
width: labelWidth - labelMargin,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
formatter: (label: string) => {
const width = Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
);
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
@@ -270,9 +278,8 @@ export class StateHistoryChartTimeline extends LitElement {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string = this.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
@@ -300,7 +307,7 @@ export class StateHistoryChartTimeline extends LitElement {
);
dataRow.push({
value: [
stateInfo.entity_id,
entityDisplay,
prevLastChanged,
newLastChanged,
locState,
@@ -326,7 +333,7 @@ export class StateHistoryChartTimeline extends LitElement {
);
dataRow.push({
value: [
stateInfo.entity_id,
entityDisplay,
prevLastChanged,
endTime,
locState,
@@ -339,10 +346,9 @@ export class StateHistoryChartTimeline extends LitElement {
});
}
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
dimensions: ["index", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
@@ -358,10 +364,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") {
const dataset = this._chartData[e.detail.dataIndex];
const dataset = this.data[e.detail.dataIndex];
if (dataset) {
fireEvent(this, "hass-more-info", {
entityId: dataset.id as string,
entityId: dataset.entity_id,
});
}
}

View File

@@ -135,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``;
}
if (!Array.isArray(item)) {
return html`<div class="entry-container line">
return html`<div class="entry-container">
<state-history-chart-line
.hass=${this.hass}
.unit=${item.unit}
@@ -157,7 +157,7 @@ export class StateHistoryCharts extends LitElement {
></state-history-chart-line>
</div> `;
}
return html`<div class="entry-container timeline">
return html`<div class="entry-container">
<state-history-chart-timeline
.hass=${this.hass}
.data=${item}
@@ -299,9 +299,6 @@ export class StateHistoryCharts extends LitElement {
.entry-container {
width: 100%;
}
.entry-container.line {
flex: 1;
}
@@ -316,10 +313,6 @@ export class StateHistoryCharts extends LitElement {
padding-inline-end: 1px;
}
.entry-container.timeline:first-child {
margin-top: var(--timeline-top-margin);
}
.entry-container:not(:first-child) {
border-top: 2px solid var(--divider-color);
margin-top: 16px;

View File

@@ -1,22 +1,15 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
BarSeriesOption,
LineSeriesOption,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeRTL } from "../../common/util/compute_rtl";
import type {
Statistics,
StatisticsMetaData,
@@ -28,9 +21,16 @@ import {
getStatisticMetadata,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -56,8 +56,6 @@ export class StatisticsChart extends LitElement {
@property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@property({ attribute: false }) public endTime?: Date;
@property({ attribute: false, type: Array })
@@ -126,10 +124,7 @@ export class StatisticsChart extends LitElement {
changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("_legendData") ||
changedProps.has("_chartData")
changedProps.has("_legendData")
) {
this._createOptions();
}
@@ -186,31 +181,18 @@ export class StatisticsChart extends LitElement {
this.requestUpdate("_hiddenStats");
}
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
private _renderTooltip = (params: any) =>
params
.map((param, index: number) => {
if (rendered[param.seriesName]) return "";
rendered[param.seriesName] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
// max series can have 3 values, as the second value is the max-min to form a band
const rawValue = String(param.value[2] ?? param.value[1]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
// max series can have 3 values, as the second value is the max-min to form a band
(param.value[2] ?? param.value[1]) as number,
this.hass.locale,
options
)}${unit}`;
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.unit}`;
const time =
index === 0
@@ -220,70 +202,36 @@ export class StatisticsChart extends LitElement {
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1;
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
const endTime = this.endTime ?? new Date();
let startTime = this.startTime;
if (!startTime) {
// set start time to the earliest point in the chart data
this._chartData.forEach((series) => {
if (!Array.isArray(series.data) || !series.data[0]) return;
const firstPoint = series.data[0] as any;
const timestamp = Array.isArray(firstPoint)
? firstPoint[0]
: firstPoint.value?.[0];
if (timestamp && (!startTime || new Date(timestamp) < startTime)) {
startTime = new Date(timestamp);
}
});
if (!startTime) {
// Calculate default start time based on dayDifference
startTime = new Date(
endTime.getTime() - dayDifference * 24 * 3600 * 1000
);
}
}
this._chartOptions = {
xAxis: [
{
id: "xAxis",
type: "time",
min: startTime,
max: this.endTime,
},
{
id: "hiddenAxis",
type: "time",
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
],
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
@@ -292,24 +240,24 @@ export class StatisticsChart extends LitElement {
align: "left",
},
position: computeRTL(this.hass) ? "right" : "left",
scale: true,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
// @ts-ignore
scale: this.chartType !== "bar",
min: this.fitYData ? undefined : this.minYAxis,
max: this.fitYData ? undefined : this.maxYAxis,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
},
legend: {
show: !this.hideLegend,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
data: this._legendData,
},
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 1,
left: 20,
right: 1,
bottom: 0,
containLabel: true,
@@ -421,12 +369,10 @@ export class StatisticsChart extends LitElement {
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push(
this._transformDataValue([prevEndTime, ...prevValues[i]!])
);
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
@@ -475,14 +421,9 @@ export class StatisticsChart extends LitElement {
displayedLegend = displayedLegend || showLegend;
}
statTypes.push(type);
const borderColor =
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
smooth: this.chartType === "line" ? 0.4 : false,
smoothMonotone: "x",
cursor: "default",
data: [],
name: name
@@ -494,7 +435,6 @@ export class StatisticsChart extends LitElement {
),
symbol: "circle",
symbolSize: 0,
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
@@ -502,16 +442,21 @@ export class StatisticsChart extends LitElement {
this.chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
borderColor,
borderColor:
band && hasMean
? color + (this.hideLegend ? "00" : "7F")
: color,
borderWidth: 1.5,
}
: undefined,
color: this.chartType === "bar" ? backgroundColor : borderColor,
color: band ? color + "3F" : color + "7F",
};
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
@@ -544,7 +489,7 @@ export class StatisticsChart extends LitElement {
}
} else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0;
val.push(Math.abs(max - (stat.min || 0)));
val.push(max - (stat.min || 0));
val.push(max);
} else {
val.push(stat[type] ?? null);
@@ -573,7 +518,6 @@ export class StatisticsChart extends LitElement {
color,
type: this.chartType,
data: [],
xAxisIndex: 1,
});
});
@@ -585,26 +529,6 @@ export class StatisticsChart extends LitElement {
this._statisticIds = statisticIds;
}
private _transformDataValue(val: [Date, ...(number | null)[]]) {
if (this.chartType === "bar" && val[1] && val[1] < 0) {
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
}
return val;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
static styles = css`
:host {
display: block;

View File

@@ -448,7 +448,6 @@ export class HaDataTable extends LitElement {
)}
@click=${this._handleHeaderClick}
.columnId=${key}
title=${ifDefined(column.title)}
>
${column.sortable
? html`

View File

@@ -57,7 +57,7 @@ class HaEntityStatePicker extends LitElement {
(this._comboBox as any).items = [
...(this.extraOptions ?? []),
...(this.entityId && stateObj
? getStates(this.hass, stateObj, this.attribute).map((key) => ({
? getStates(stateObj, this.attribute).map((key) => ({
value: key,
label: !this.attribute
? this.hass.formatEntityState(stateObj, key)

View File

@@ -295,12 +295,10 @@ export class HaAssistChat extends LitElement {
this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
let hassMessage = {
const hassMessage: AssistMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
try {
const unsub = await runAssistPipeline(
@@ -330,43 +328,6 @@ export class HaAssistChat extends LitElement {
this._addMessage(hassMessage);
}
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (currentDeltaRole && delta.role && hassMessage.text !== "…") {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
@@ -474,71 +435,28 @@ export class HaAssistChat extends LitElement {
this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text });
let hassMessage = {
const message: AssistMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
this._addMessage(message);
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message and previous message has content
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (
currentDeltaRole &&
delta.role === "assistant" &&
hassMessage.text !== "…"
) {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
message.text = plain.speech;
}
this.requestUpdate("_conversation");
unsub();
}
if (event.type === "error") {
hassMessage.text = event.data.message;
hassMessage.error = true;
message.text = event.data.message;
message.error = true;
this.requestUpdate("_conversation");
unsub();
}
@@ -552,8 +470,8 @@ export class HaAssistChat extends LitElement {
}
);
} catch {
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
hassMessage.error = true;
message.text = this.hass.localize("ui.dialogs.voice_command.error");
message.error = true;
this.requestUpdate("_conversation");
} finally {
this._processing = false;

View File

@@ -329,12 +329,15 @@ export class HaBaseTimeInput extends LitElement {
:host([clearable]) {
position: relative;
}
:host {
display: block;
}
.time-input-wrap-wrap {
display: flex;
}
.time-input-wrap {
display: flex;
flex: var(--time-input-flex, unset);
flex: 1;
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;

View File

@@ -5,7 +5,6 @@ import "@material/mwc-list/mwc-list-item";
import { mdiCalendar } from "@mdi/js";
import {
addDays,
subHours,
endOfDay,
endOfMonth,
endOfWeek,
@@ -16,7 +15,6 @@ import {
startOfYear,
isThisYear,
} from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -24,18 +22,16 @@ import { ifDefined } from "lit/directives/if-defined";
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
formatShortDateTime,
} from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./date-range-picker";
import "./ha-icon-button";
import "./ha-textarea";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -179,96 +175,6 @@ export class HaDateRangePicker extends LitElement {
weekStartsOn,
}),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-1h"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
1
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-12h"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
12
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-24h"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
24
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-7d"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
24 * 7
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-30d"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
24 * 30
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
}
: {}),
};
@@ -291,15 +197,14 @@ export class HaDateRangePicker extends LitElement {
?auto-apply=${this.autoApply}
time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
start-date=${this._formatDate(this.startDate)}
end-date=${this._formatDate(this.endDate)}
start-date=${this.startDate.toISOString()}
end-date=${this.endDate.toISOString()}
?ranges=${this.ranges !== false}
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}
>
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal
@@ -420,31 +325,9 @@ export class HaDateRangePicker extends LitElement {
}
private _applyDateRange() {
if (this.hass.locale.time_zone === TimeZone.server) {
const dateRangePicker = this._dateRangePicker;
const startDate = fromZonedTime(
dateRangePicker.start,
this.hass.config.time_zone
);
const endDate = fromZonedTime(
dateRangePicker.end,
this.hass.config.time_zone
);
dateRangePicker.clickRange([startDate, endDate]);
}
this._dateRangePicker.clickedApply();
}
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return toZonedTime(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}
private get _dateRangePicker() {
const dateRangePicker = this.shadowRoot!.querySelector(
"date-range-picker"
@@ -475,66 +358,45 @@ export class HaDateRangePicker extends LitElement {
}
}
private _handleChange(ev: CustomEvent) {
ev.stopPropagation();
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
fireEvent(this, "value-changed", {
value: { startDate, endDate },
});
}
static styles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: 8px;
}
ha-icon-button {
direction: var(--direction);
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.date-range-footer {
display: flex;
justify-content: flex-end;
padding: 8px;
border-top: 1px solid var(--divider-color);
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
}
.date-range-footer {
display: flex;
justify-content: flex-end;
padding: 8px;
border-top: 1px solid var(--divider-color);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
display: inline-block;
width: 340px;
}
}
@media only screen and (max-width: 800px) {
.date-range-ranges {
border-right: none;
border-bottom: 1px solid var(--divider-color);
}
}
@media only screen and (max-height: 940px) and (max-width: 800px) {
.date-range-ranges {
overflow: auto;
max-height: calc(70vh - 330px);
min-height: 160px;
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%
}
:host([header-position]) .date-range-ranges {
max-height: calc(90vh - 430px);
@media only screen and (max-width: 800px) {
.date-range-ranges {
border-right: none;
border-bottom: 1px solid var(--divider-color);
}
}
}
`;
`;
}
declare global {

View File

@@ -11,7 +11,6 @@ import "./ha-icon-button";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import { ensureArray } from "../common/array/ensure-array";
import { bytesToString } from "../util/bytes-to-string";
import type { LocalizeFunc } from "../common/translations/localize";
declare global {
interface HASSDomEvents {
@@ -24,8 +23,6 @@ declare global {
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property() public accept!: string;
@property() public icon?: string;
@@ -34,10 +31,6 @@ export class HaFileUpload extends LitElement {
@property() public secondary?: string;
@property({ attribute: "uploading-label" }) public uploadingLabel?: string;
@property({ attribute: "delete-label" }) public deleteLabel?: string;
@property() public supports?: string;
@property({ type: Object }) public value?: File | File[] | FileList | string;
@@ -80,22 +73,23 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this.hass!.localize;
return html`
${this.uploading
? html`<div class="container">
<div class="uploading">
<span class="header"
>${this.uploadingLabel || this.value
? localize("ui.components.file-upload.uploading_name", {
name: this._name,
})
: localize("ui.components.file-upload.uploading")}</span
>${this.value
? this.hass?.localize(
"ui.components.file-upload.uploading_name",
{ name: this._name }
)
: this.hass?.localize(
"ui.components.file-upload.uploading"
)}</span
>
${this.progress
? html`<div class="progress">
${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
${this.progress}${blankBeforePercent(this.hass!.locale)}%
</div>`
: nothing}
</div>
@@ -122,11 +116,14 @@ export class HaFileUpload extends LitElement {
.path=${this.icon || mdiFileUpload}
></ha-svg-icon>
<ha-button unelevated @click=${this._openFilePicker}>
${this.label || localize("ui.components.file-upload.label")}
${this.label ||
this.hass?.localize("ui.components.file-upload.label")}
</ha-button>
<span class="secondary"
>${this.secondary ||
localize("ui.components.file-upload.secondary")}</span
this.hass?.localize(
"ui.components.file-upload.secondary"
)}</span
>
<span class="supports">${this.supports}</span>`
: typeof this.value === "string"
@@ -139,7 +136,8 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.deleteLabel || localize("ui.common.delete")}
.label=${this.hass?.localize("ui.common.delete") ||
"Delete"}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -157,8 +155,8 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.deleteLabel ||
localize("ui.common.delete")}
.label=${this.hass?.localize("ui.common.delete") ||
"Delete"}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -240,10 +238,6 @@ export class HaFileUpload extends LitElement {
border-radius: var(--mdc-shape-small, 4px);
height: 100%;
}
.row {
display: flex;
align-items: center;
}
label.container {
border: dashed 1px
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));

View File

@@ -1,12 +1,7 @@
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown";
import { CacheManager } from "../util/cache-manager";
const markdownCache = new CacheManager<string>(1000);
const _gitHubMarkdownAlerts = {
reType:
@@ -31,16 +26,6 @@ class HaMarkdownElement extends ReactiveElement {
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
false;
@property({ type: Boolean }) public cache = false;
public disconnectedCallback() {
super.disconnectedCallback();
if (this.cache) {
const key = this._computeCacheKey();
markdownCache.set(key, this.innerHTML);
}
}
protected createRenderRoot() {
return this;
}
@@ -52,24 +37,6 @@ class HaMarkdownElement extends ReactiveElement {
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey();
if (markdownCache.has(key)) {
this.innerHTML = markdownCache.get(key)!;
this._resize();
}
}
}
private _computeCacheKey() {
return hash({
content: this.content,
allowSvg: this.allowSvg,
breaks: this.breaks,
});
}
private async _render() {
this.innerHTML = await renderMarkdown(
String(this.content),

View File

@@ -13,8 +13,6 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
false;
@property({ type: Boolean }) public cache = false;
protected render() {
if (!this.content) {
return nothing;
@@ -25,7 +23,6 @@ export class HaMarkdown extends LitElement {
.allowSvg=${this.allowSvg}
.breaks=${this.breaks}
.lazyImages=${this.lazyImages}
.cache=${this.cache}
></ha-markdown-element>`;
}

View File

@@ -64,13 +64,9 @@ export class HaNetwork extends LitElement {
>
</ha-checkbox>
</span>
<span slot="heading" data-for="auto_configure">
${this.hass.localize(
"ui.panel.config.network.adapter.auto_configure"
)}
</span>
<span slot="heading" data-for="auto_configure"> Auto Configure </span>
<span slot="description" data-for="auto_configure">
${this.hass.localize("ui.panel.config.network.adapter.detected")}:
Detected:
${format_auto_detected_interfaces(this.networkConfig.adapters)}
</span>
</ha-settings-row>
@@ -89,21 +85,18 @@ export class HaNetwork extends LitElement {
</ha-checkbox>
</span>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.network.adapter.adapter"
)}:
${adapter.name}
Adapter: ${adapter.name}
${adapter.default
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
(${this.hass.localize("ui.common.default")})`
: nothing}
(Default)`
: ""}
</span>
<span slot="description">
${format_addresses([...adapter.ipv4, ...adapter.ipv6])}
</span>
</ha-settings-row>`
)
: nothing}
: ""}
`;
}

View File

@@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators";
// and "qr-scanner" defaults to a suboptimal implementation if it is not available.
// The following import makes a better implementation available that is based on a
// WebAssembly port of ZXing:
import { prepareZXingModule } from "barcode-detector";
import { setZXingModuleOverrides } from "barcode-detector";
import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
@@ -21,14 +21,12 @@ import "./ha-list-item";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
prepareZXingModule({
overrides: {
locateFile: (path: string, prefix: string) => {
if (path.endsWith(".wasm")) {
return "/static/js/zxing_reader.wasm";
}
return prefix + path;
},
setZXingModuleOverrides({
locateFile: (path: string, prefix: string) => {
if (path.endsWith(".wasm")) {
return "/static/js/zxing_reader.wasm";
}
return prefix + path;
},
});

View File

@@ -8,10 +8,9 @@ import type {
Map,
Marker,
Polyline,
MarkerClusterGroup,
} from "leaflet";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { ReactiveElement, css } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDateTime } from "../../common/datetime/format_date_time";
@@ -27,7 +26,6 @@ import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { DecoratedMarker } from "../../common/map/decorated_marker";
declare global {
// for fire event
@@ -86,9 +84,6 @@ export class HaMap extends ReactiveElement {
@property({ type: Number }) public zoom = 14;
@property({ attribute: "cluster-markers", type: Boolean })
public clusterMarkers = true;
@state() private _loaded = false;
public leafletMap?: Map;
@@ -101,12 +96,10 @@ export class HaMap extends ReactiveElement {
private _mapFocusItems: (Marker | Circle)[] = [];
private _mapZones: DecoratedMarker[] = [];
private _mapZones: (Marker | Circle)[] = [];
private _mapFocusZones: (Marker | Circle)[] = [];
private _mapCluster: MarkerClusterGroup | undefined;
private _mapPaths: (Polyline | CircleMarker)[] = [];
private _clickCount = 0;
@@ -158,10 +151,6 @@ export class HaMap extends ReactiveElement {
}
}
if (changedProps.has("clusterMarkers")) {
this._drawEntities();
}
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
}
@@ -186,7 +175,6 @@ export class HaMap extends ReactiveElement {
) {
return;
}
this._updateMapStyle();
}
@@ -438,11 +426,6 @@ export class HaMap extends ReactiveElement {
this._mapFocusZones = [];
}
if (this._mapCluster) {
this._mapCluster.remove();
this._mapCluster = undefined;
}
if (!this.entities) {
return;
}
@@ -498,24 +481,26 @@ export class HaMap extends ReactiveElement {
iconHTML = el.outerHTML;
}
// create marker with the icon
this._mapZones.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className,
}),
interactive: this.interactiveZones,
title,
})
);
// create circle around it
const circle = Leaflet.circle([latitude, longitude], {
interactive: false,
color: passive ? passiveZoneColor : zoneColor,
radius,
});
const marker = new DecoratedMarker([latitude, longitude], circle, {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className,
}),
interactive: this.interactiveZones,
title,
});
this._mapZones.push(marker);
this._mapZones.push(circle);
if (
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
@@ -553,7 +538,7 @@ export class HaMap extends ReactiveElement {
}
// create marker with the icon
const marker = new DecoratedMarker([latitude, longitude], undefined, {
const marker = Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: entityMarker,
iconSize: [48, 48],
@@ -561,33 +546,24 @@ export class HaMap extends ReactiveElement {
}),
title: title,
});
this._mapItems.push(marker);
if (typeof entity === "string" || entity.focus !== false) {
this._mapFocusItems.push(marker);
}
// create circle around if entity has accuracy
if (gpsAccuracy) {
marker.decorationLayer = Leaflet.circle([latitude, longitude], {
interactive: false,
color: darkPrimaryColor,
radius: gpsAccuracy,
});
this._mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: darkPrimaryColor,
radius: gpsAccuracy,
})
);
}
this._mapItems.push(marker);
}
if (this.clusterMarkers) {
this._mapCluster = Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
});
this._mapCluster.addLayers(this._mapItems);
map.addLayer(this._mapCluster);
} else {
this._mapItems.forEach((marker) => map.addLayer(marker));
}
this._mapItems.forEach((marker) => map.addLayer(marker));
this._mapZones.forEach((marker) => map.addLayer(marker));
}

View File

@@ -1,81 +1,25 @@
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
import "../ha-icon";
import "../ha-svg-icon";
import { classMap } from "lit/directives/class-map";
export type TileIconImageStyle = "square" | "rounded-square" | "circle";
export const DEFAULT_TILE_ICON_BORDER_STYLE = "circle";
@customElement("ha-tile-icon")
export class HaTileIcon extends LitElement {
@property({ type: Boolean, reflect: true })
public interactive = false;
@property({ attribute: "border-style", type: String })
public imageStyle?: TileIconImageStyle;
@property({ attribute: false })
public imageUrl?: string;
protected render(): TemplateResult {
if (this.imageUrl) {
const imageStyle = this.imageStyle || DEFAULT_TILE_ICON_BORDER_STYLE;
return html`
<div class="container ${classMap({ [imageStyle]: this.imageUrl })}">
<img alt="" src=${this.imageUrl} />
</div>
<slot></slot>
`;
}
return html`
<div class="container ${this.interactive ? "background" : ""}">
<slot name="icon"></slot>
<div class="shape">
<slot></slot>
</div>
<slot></slot>
`;
}
static styles = css`
:host {
--tile-icon-color: var(--disabled-color);
--tile-icon-opacity: 0.2;
--tile-icon-hover-opacity: 0.35;
--mdc-icon-size: 24px;
position: relative;
user-select: none;
transition: transform 180ms ease-in-out;
--mdc-icon-size: 22px;
}
:host([interactive]:active) {
transform: scale(1.2);
}
:host([interactive]:hover) {
--tile-icon-opacity: var(--tile-icon-hover-opacity);
}
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 18px;
overflow: hidden;
transition: box-shadow 180ms ease-in-out;
}
:host([interactive]:focus-visible) .container {
box-shadow: 0 0 0 2px var(--tile-icon-color);
}
.container.rounded-square {
border-radius: 8px;
}
.container.square {
border-radius: 0;
}
.container.background::before {
.shape::before {
content: "";
position: absolute;
top: 0;
@@ -83,21 +27,24 @@ export class HaTileIcon extends LitElement {
height: 100%;
width: 100%;
background-color: var(--tile-icon-color);
transition:
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
opacity: var(--tile-icon-opacity);
transition: background-color 180ms ease-in-out;
opacity: 0.2;
}
.container ::slotted([slot="icon"]) {
.shape {
position: relative;
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: color 180ms ease-in-out;
overflow: hidden;
}
.shape ::slotted(*) {
display: flex;
color: var(--tile-icon-color);
transition: color 180ms ease-in-out;
pointer-events: none;
}
.container img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
}

View File

@@ -0,0 +1,53 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
export type TileImageStyle = "square" | "rounded-square" | "circle";
@customElement("ha-tile-image")
export class HaTileImage extends LitElement {
@property({ attribute: false }) public imageUrl?: string;
@property({ attribute: false }) public imageAlt?: string;
@property({ attribute: false }) public imageStyle: TileImageStyle = "circle";
protected render() {
return html`
<div class="image ${this.imageStyle}">
${this.imageUrl
? html`<img alt=${ifDefined(this.imageAlt)} src=${this.imageUrl} />`
: nothing}
</div>
`;
}
static styles = css`
.image {
position: relative;
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image.rounded-square {
border-radius: 8%;
}
.image.square {
border-radius: 0;
}
.image img {
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-tile-image": HaTileImage;
}
}

View File

@@ -1,4 +1,4 @@
import { css, html, LitElement, nothing, svg } from "lit";
import { css, html, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
@@ -41,8 +41,8 @@ export class HatGraphBranch extends LitElement {
branches.push({
x: width / 2 + total_width,
height,
start: c.hasAttribute("graph-start"),
end: c.hasAttribute("graph-end"),
start: c.hasAttribute("graphStart"),
end: c.hasAttribute("graphEnd"),
track: c.hasAttribute("track"),
});
total_width += width;
@@ -65,8 +65,11 @@ export class HatGraphBranch extends LitElement {
return html`
<slot name="head"></slot>
${!this.start
? html`
<svg id="top" width=${this._totalWidth}>
? svg`
<svg
id="top"
width="${this._totalWidth}"
>
${this._branches.map((branch) =>
branch.start
? ""
@@ -83,7 +86,7 @@ export class HatGraphBranch extends LitElement {
)}
</svg>
`
: nothing}
: ""}
<div id="branches">
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
${this._branches.map((branch) => {
@@ -104,8 +107,11 @@ export class HatGraphBranch extends LitElement {
</div>
${!this.short
? html`
<svg id="bottom" width=${this._totalWidth}>
? svg`
<svg
id="bottom"
width="${this._totalWidth}"
>
${this._branches.map((branch) => {
if (branch.end) return "";
return svg`
@@ -122,7 +128,7 @@ export class HatGraphBranch extends LitElement {
})}
</svg>
`
: nothing}
: ""}
`;
}

View File

@@ -108,34 +108,6 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
intent_input: string;
};
}
interface ConversationChatLogAssistantDelta {
role: "assistant";
content: string;
tool_calls: {
id: string;
tool_name: string;
tool_args: Record<string, unknown>;
}[];
}
interface ConversationChatLogToolResultDelta {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: unknown;
}
interface PipelineIntentProgressEvent extends PipelineEventBase {
type: "intent-progress";
data: {
chat_log_delta:
| Partial<ConversationChatLogAssistantDelta>
// These always come in 1 chunk
| ConversationChatLogToolResultDelta;
};
}
interface PipelineIntentEndEvent extends PipelineEventBase {
type: "intent-end";
data: {
@@ -169,7 +141,6 @@ export type PipelineRunEvent =
| PipelineSTTStartEvent
| PipelineSTTEndEvent
| PipelineIntentStartEvent
| PipelineIntentProgressEvent
| PipelineIntentEndEvent
| PipelineTTSStartEvent
| PipelineTTSEndEvent;

View File

@@ -1,4 +1,3 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
@@ -12,9 +11,7 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
import type { BackupManagerState, ManagerStateEvent } from "./backup_manager";
import checkValidDate from "../common/datetime/check_valid_date";
import { handleFetchPromise } from "../util/hass-call-api";
export const enum BackupScheduleRecurrence {
NEVER = "never",
@@ -107,9 +104,6 @@ export interface BackupContent {
name: string;
agents: Record<string, BackupContentAgent>;
failed_agent_ids?: string[];
extra_metadata?: {
"supervisor.addon_update"?: string;
};
with_automatic_settings: boolean;
}
@@ -131,13 +125,7 @@ export interface BackupContentExtended extends BackupContent, BackupData {}
export interface BackupInfo {
backups: BackupContent[];
agent_errors: Record<string, string>;
last_attempted_automatic_backup: string | null;
last_completed_automatic_backup: string | null;
last_non_idle_event: ManagerStateEvent | null;
next_automatic_backup: string | null;
next_automatic_backup_additional: boolean;
state: BackupManagerState;
backing_up: boolean;
}
export interface BackupDetails {
@@ -239,23 +227,27 @@ export const restoreBackup = (
export const uploadBackup = async (
hass: HomeAssistant,
file: File,
agentIds: string[]
): Promise<{ backup_id: string }> => {
agent_ids: string[]
): Promise<void> => {
const fd = new FormData();
fd.append("file", file);
const params = new URLSearchParams();
const params = agent_ids.reduce((acc, agent_id) => {
acc.append("agent_id", agent_id);
return acc;
}, new URLSearchParams());
agentIds.forEach((agentId) => {
params.append("agent_id", agentId);
});
return handleFetchPromise(
hass.fetchWithAuth(`/api/backup/upload?${params.toString()}`, {
const resp = await hass.fetchWithAuth(
`/api/backup/upload?${params.toString()}`,
{
method: "POST",
body: fd,
})
}
);
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
};
export const getPreferredAgentForDownload = (agents: string[]) => {
@@ -327,29 +319,6 @@ export const computeBackupAgentName = (
export const computeBackupSize = (backup: BackupContent) =>
Math.max(...Object.values(backup.agents).map((agent) => agent.size));
export type BackupType = "automatic" | "manual" | "addon_update";
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "manual", "addon_update"];
export const getBackupTypes = memoize((isHassio: boolean) =>
isHassio
? BACKUP_TYPE_ORDER
: BACKUP_TYPE_ORDER.filter((type) => type !== "addon_update")
);
export const computeBackupType = (
backup: BackupContent,
isHassio: boolean
): BackupType => {
if (backup.with_automatic_settings) {
return "automatic";
}
if (isHassio && backup.extra_metadata?.["supervisor.addon_update"] != null) {
return "addon_update";
}
return "manual";
};
export const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b);
@@ -453,13 +422,3 @@ export const getFormattedBackupTime = memoizeOne(
return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`;
}
);
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
export interface BackupUploadFileFormData {
file?: File;
}
export const INITIAL_UPLOAD_FORM_DATA: BackupUploadFileFormData = {
file: undefined,
};

View File

@@ -1,66 +0,0 @@
import { handleFetchPromise } from "../util/hass-call-api";
import type { BackupContentExtended } from "./backup";
import type {
BackupManagerState,
RestoreBackupStage,
RestoreBackupState,
} from "./backup_manager";
export interface BackupOnboardingInfo {
state: BackupManagerState;
last_non_idle_event?: {
manager_state: BackupManagerState;
stage: RestoreBackupStage | null;
state: RestoreBackupState;
reason: string | null;
} | null;
}
export interface BackupOnboardingConfig extends BackupOnboardingInfo {
backups: BackupContentExtended[];
}
export const fetchBackupOnboardingInfo = async () =>
handleFetchPromise<BackupOnboardingConfig>(
fetch("/api/onboarding/backup/info")
);
export interface RestoreOnboardingBackupParams {
backup_id: string;
agent_id: string;
password?: string;
restore_addons?: string[];
restore_database?: boolean;
restore_folders?: string[];
}
export const restoreOnboardingBackup = async (
params: RestoreOnboardingBackupParams
) =>
handleFetchPromise(
fetch("/api/onboarding/backup/restore", {
method: "POST",
body: JSON.stringify(params),
})
);
export const uploadOnboardingBackup = async (
file: File,
agentIds: string[]
): Promise<{ backup_id: string }> => {
const fd = new FormData();
fd.append("file", file);
const params = new URLSearchParams();
agentIds.forEach((agentId) => {
params.append("agent_id", agentId);
});
return handleFetchPromise(
fetch(`/api/onboarding/backup/upload?${params.toString()}`, {
method: "POST",
body: fd,
})
);
};

View File

@@ -181,6 +181,3 @@ export const updateCloudGoogleEntityConfig = (
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/google_actions/sync");
export const fetchSupportPackage = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "cloud/support_package");

View File

@@ -19,8 +19,6 @@ export interface ConfigEntry {
supports_remove_device: boolean;
supports_unload: boolean;
supports_reconfigure: boolean;
supported_subentry_types: Record<string, { supports_reconfigure: boolean }>;
num_subentries: number;
pref_disable_new_entities: boolean;
pref_disable_polling: boolean;
disabled_by: "user" | null;
@@ -29,30 +27,6 @@ export interface ConfigEntry {
error_reason_translation_placeholders: Record<string, string> | null;
}
export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
hass.callWS<SubEntry[]>({
type: "config_entries/subentries/list",
entry_id,
});
export const deleteSubEntry = (
hass: HomeAssistant,
entry_id: string,
subentry_id: string
) =>
hass.callWS({
type: "config_entries/subentries/delete",
entry_id,
subentry_id,
});
export type ConfigEntryMutableParams = Partial<
Pick<
ConfigEntry,

View File

@@ -2,11 +2,7 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries";
export type FlowType =
| "config_flow"
| "config_subentries_flow"
| "options_flow"
| "repair_flow";
export type FlowType = "config_flow" | "options_flow" | "repair_flow";
export interface DataEntryFlowProgressedEvent {
type: "data_entry_flow_progressed";

View File

@@ -17,7 +17,6 @@ export {
export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
connections: [string, string][];
identifiers: [string, string][];
manufacturer: string | null;

View File

@@ -50,7 +50,6 @@ export interface EntityRegistryEntry extends RegistryEntry {
icon: string | null;
platform: string;
config_entry_id: string | null;
config_subentry_id: string | null;
device_id: string | null;
area_id: string | null;
labels: string[];

View File

@@ -1,5 +1,6 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import { handleFetchPromise } from "../../util/hass-call-api";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
@@ -81,24 +82,34 @@ export const fetchHassioBackups = async (
};
export const fetchHassioBackupInfo = async (
hass: HomeAssistant,
hass: HomeAssistant | undefined,
backup: string
): Promise<HassioBackupDetail> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`,
method: "get",
});
if (hass) {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioBackupDetail>>(
"GET",
`hassio/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`
)
);
}
// When called from onboarding we don't have hass
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioBackupDetail>>(
"GET",
`hassio/${
atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots"
}/${backup}/info`
await handleFetchPromise(
fetch(`/api/hassio/backups/${backup}/info`, {
method: "GET",
})
)
);
};
@@ -229,15 +240,24 @@ export const uploadBackup = async (
};
export const restoreBackup = async (
hass: HomeAssistant,
hass: HomeAssistant | undefined,
type: HassioBackupDetail["type"],
backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useBackupUrl: boolean
useSnapshotUrl: boolean
): Promise<void> => {
await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST",
`hassio/${useBackupUrl ? "backups" : "snapshots"}/${backupSlug}/restore/${type}`,
backupDetails
);
if (hass) {
await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
backupDetails
);
} else {
await handleFetchPromise(
fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, {
method: "POST",
body: JSON.stringify(backupDetails),
})
);
}
};

View File

@@ -1,46 +0,0 @@
import type { HomeAssistant } from "../types";
import type { DataEntryFlowStep } from "./data_entry_flow";
const HEADERS = {
"HA-Frontend-Base": `${location.protocol}//${location.host}`,
};
export const createSubConfigFlow = (
hass: HomeAssistant,
configEntryId: string,
subFlowType: string,
subentry_id?: string
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
);
export const fetchSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi<DataEntryFlowStep>(
"GET",
`config/config_entries/subentries/flow/${flowId}`,
undefined,
HEADERS
);
export const handleSubConfigFlowStep = (
hass: HomeAssistant,
flowId: string,
data: Record<string, any>
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
`config/config_entries/subentries/flow/${flowId}`,
data,
HEADERS
);
export const deleteSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi("DELETE", `config/config_entries/subentries/flow/${flowId}`);

View File

@@ -63,7 +63,6 @@ export type TranslationCategory =
| "entity_component"
| "exceptions"
| "config"
| "config_subentries"
| "config_panel"
| "options"
| "device_automation"

View File

@@ -282,8 +282,6 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult}
></step-flow-create-entry>
`}
`}

View File

@@ -77,7 +77,7 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
if (this.flowType === "repair_flow") {
return;
}
try {

View File

@@ -147,7 +147,7 @@ class FlowPreviewTemplate extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
if (this.flowType === "repair_flow") {
return;
}
try {

View File

@@ -16,9 +16,7 @@ export const loadConfigFlowDialog = loadDataEntryFlowDialog;
export const showConfigFlowDialog = (
element: HTMLElement,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
entryId?: string;
}
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig">
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_flow",

View File

@@ -148,8 +148,8 @@ export interface DataEntryFlowDialogParams {
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
entryId?: string;
dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
}
export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow");

View File

@@ -1,275 +0,0 @@
import { html } from "lit";
import type { ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import {
createSubConfigFlow,
deleteSubConfigFlow,
fetchSubConfigFlow,
handleSubConfigFlowStep,
} from "../../data/sub_config_flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
export const loadSubConfigFlowDialog = loadDataEntryFlowDialog;
export const showSubConfigFlowDialog = (
element: HTMLElement,
configEntry: ConfigEntry,
flowType: string,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
subEntryId?: string;
}
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_subentries_flow",
showDevices: true,
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createSubConfigFlow(hass, handler, flowType, dialogParams.subEntryId),
hass.loadFragmentTranslation("config"),
hass.loadBackendTranslation("config_subentries", configEntry.domain),
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
]);
return step;
},
fetchFlow: async (hass, flowId) => {
const step = await fetchSubConfigFlow(hass, flowId);
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation(
"config_subentries",
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: step.reason;
},
renderShowFormStepHeader(hass, step) {
return (
hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderShowFormStepFieldLabel(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.name`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data.${field.name}`
) || field.name
);
},
renderShowFormStepFieldHelper(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.description`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data_description.${field.name}`,
step.description_placeholders
);
return description
? html`<ha-markdown breaks .content=${description}></ha-markdown>`
: "";
},
renderShowFormStepFieldError(hass, step, error) {
return (
hass.localize(
`component.${step.translation_domain || step.translation_domain || configEntry.domain}.config_subentries.${flowType}.error.${error}`,
step.description_placeholders
) || error
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
},
renderShowFormStepSubmitButton(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.submit`
) ||
hass.localize(
`ui.panel.config.integrations.config_flow.${
step.last_step === false ? "next" : "submit"
}`
)
);
},
renderExternalStepHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) ||
hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)
);
},
renderExternalStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return html`
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.external_step.description"
)}
</p>
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
`;
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.create_entry.${
step.description || "default"
}`,
step.description_placeholders
);
return html`
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: step.title }
)}
</p>
`;
},
renderShowFormProgressHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormProgressDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.progress.${step.progress_action}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderMenuDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuOption(hass, step, option) {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.menu_options.${option}`,
step.description_placeholders
);
},
renderLoadingDescription(hass, reason, handler, step) {
if (reason !== "loading_flow" && reason !== "loading_step") {
return "";
}
const domain = step?.handler || handler;
return hass.localize(
`ui.panel.config.integrations.config_flow.loading.${reason}`,
{
integration: domain
? domainToName(hass.localize, domain)
: // when we are continuing a config flow, we only know the ID and not the domain
hass.localize(
"ui.panel.config.integrations.config_flow.loading.fallback_title"
),
}
);
},
});

View File

@@ -60,7 +60,6 @@ class StepFlowAbort extends LitElement {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.domain,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.params.navigateToResult,
});
},
});

View File

@@ -19,7 +19,6 @@ import { showAlertDialog } from "../generic/show-dialog-box";
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import { navigate } from "../../common/navigate";
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@@ -29,8 +28,6 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
navigateToResult = false;
private _devices = memoizeOne(
(
showDevices: boolean,
@@ -68,8 +65,7 @@ class StepFlowCreateEntry extends LitElement {
if (
devices.length !== 1 ||
devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
devices[0].primary_config_entry !== this.step.result?.entry_id
) {
return;
}
@@ -85,7 +81,6 @@ class StepFlowCreateEntry extends LitElement {
assistSatelliteSupportsSetupFlow(this.hass.states[satellite.entity_id])
)
) {
this.navigateToResult = false;
this._flowDone();
showVoiceAssistantSetupDialog(this, {
deviceId: devices[0].id,
@@ -156,11 +151,6 @@ class StepFlowCreateEntry extends LitElement {
private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined });
if (this.step.result && this.navigateToResult) {
navigate(
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
);
}
}
private async _areaPicked(ev: CustomEvent) {

View File

@@ -49,8 +49,6 @@ class LightRgbColorPicker extends LitElement {
@state() private _hsPickerValue?: [number, number];
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
@@ -213,10 +211,7 @@ class LightRgbColorPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
this._isInteracting ||
(!changedProps.has("entityId") && !changedProps.has("hass"))
) {
if (!changedProps.has("entityId") && !changedProps.has("hass")) {
return;
}
@@ -224,13 +219,10 @@ class LightRgbColorPicker extends LitElement {
}
private _hsColorCursorMoved(ev: CustomEvent) {
const color = ev.detail.value;
this._isInteracting = color !== undefined;
if (color === undefined) {
if (!ev.detail.value) {
return;
}
this._hsPickerValue = color;
this._hsPickerValue = ev.detail.value;
this._throttleUpdateColor();
}

View File

@@ -22,6 +22,7 @@ import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity_attributes";
declare global {
interface HASSDomEvents {
"color-changed": LightColor;
"color-hovered": LightColor | undefined;
}
}
@@ -53,8 +54,6 @@ class LightColorTempPicker extends LitElement {
@state() private _ctPickerValue?: number;
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
@@ -114,7 +113,7 @@ class LightColorTempPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
if (!changedProps.has("stateObj")) {
return;
}
@@ -124,14 +123,16 @@ class LightColorTempPicker extends LitElement {
private _ctColorCursorMoved(ev: CustomEvent) {
const ct = ev.detail.value;
this._isInteracting = ct !== undefined;
if (isNaN(ct) || this._ctPickerValue === ct) {
return;
}
this._ctPickerValue = ct;
fireEvent(this, "color-hovered", {
color_temp_kelvin: ct,
});
this._throttleUpdateColorTemp();
}
@@ -142,6 +143,8 @@ class LightColorTempPicker extends LitElement {
private _ctColorChanged(ev: CustomEvent) {
const ct = ev.detail.value;
fireEvent(this, "color-hovered", undefined);
if (isNaN(ct) || this._ctPickerValue === ct) {
return;
}

View File

@@ -33,7 +33,6 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"switch",
"valve",
"water_heater",
"weather",
];
/** Domains with full height more info dialog */
export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"];

View File

@@ -448,10 +448,6 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
margin-bottom: -16px;
margin-top: -4px;
--md-sys-color-surface: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
}
ha-md-list-item {

View File

@@ -1,13 +1,18 @@
import "@material/mwc-tab";
import "@material/mwc-tab-bar";
import { mdiEye, mdiGauge, mdiWaterPercent, mdiWeatherWindy } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import {
mdiEye,
mdiGauge,
mdiThermometer,
mdiWaterPercent,
mdiWeatherWindy,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { formatNumber } from "../../../common/number/format_number";
import { formatDateWeekdayDay } from "../../../common/datetime/format_date";
import { formatTimeWeekday } from "../../../common/datetime/format_time";
import "../../../components/ha-svg-icon";
import type {
ForecastEvent,
@@ -18,16 +23,11 @@ import {
getDefaultForecastType,
getForecast,
getSupportedForecastTypes,
getSecondaryWeatherAttribute,
getWeatherStateIcon,
getWeatherUnit,
getWind,
subscribeForecast,
weatherSVGStyles,
weatherIcons,
} from "../../../data/weather";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-relative-time";
import "../../../components/ha-state-icon";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@@ -137,90 +137,23 @@ class MoreInfoWeather extends LitElement {
const hourly = forecastData?.type === "hourly";
const dayNight = forecastData?.type === "twice_daily";
const weatherStateIcon = getWeatherStateIcon(this.stateObj.state, this);
return html`
<div class="content">
<div class="icon-image">
${weatherStateIcon ||
html`
<ha-state-icon
class="weather-icon"
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-icon>
`}
</div>
<div class="info">
<div class="name-state">
<div class="state">
${this.hass.formatEntityState(this.stateObj)}
${this._showValue(this.stateObj.attributes.temperature)
? html`
<div class="flex">
<ha-svg-icon .path=${mdiThermometer}></ha-svg-icon>
<div class="main">
${this.hass.localize("ui.card.weather.attributes.temperature")}
</div>
<div>
${this.hass.formatEntityAttributeValue(
this.stateObj,
"temperature"
)}
</div>
</div>
<div class="time-ago">
<ha-relative-time
id="last_changed"
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
<simple-tooltip animation-delay="0" for="last_changed">
<div>
<div class="row">
<span class="column-name">
${this.hass.localize(
"ui.dialogs.more_info_control.last_changed"
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
</div>
<div class="row">
<span>
${this.hass.localize(
"ui.dialogs.more_info_control.last_updated"
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_updated}
capitalize
></ha-relative-time>
</div>
</div>
</simple-tooltip>
</div>
</div>
<div class="temp-attribute">
<div class="temp">
${this.stateObj.attributes.temperature !== undefined &&
this.stateObj.attributes.temperature !== null
? html`
${formatNumber(
this.stateObj.attributes.temperature,
this.hass.locale
)}&nbsp;<span
>${getWeatherUnit(
this.hass.config,
this.stateObj,
"temperature"
)}</span
>
`
: nothing}
</div>
<div class="attribute">
${getSecondaryWeatherAttribute(
this.hass,
this.stateObj,
forecast!
)}
</div>
</div>
</div>
</div>
`
: ""}
${this._showValue(this.stateObj.attributes.pressure)
? html`
<div class="flex">
@@ -236,7 +169,7 @@ class MoreInfoWeather extends LitElement {
</div>
</div>
`
: nothing}
: ""}
${this._showValue(this.stateObj.attributes.humidity)
? html`
<div class="flex">
@@ -252,7 +185,7 @@ class MoreInfoWeather extends LitElement {
</div>
</div>
`
: nothing}
: ""}
${this._showValue(this.stateObj.attributes.wind_speed)
? html`
<div class="flex">
@@ -270,7 +203,7 @@ class MoreInfoWeather extends LitElement {
</div>
</div>
`
: nothing}
: ""}
${this._showValue(this.stateObj.attributes.visibility)
? html`
<div class="flex">
@@ -286,7 +219,7 @@ class MoreInfoWeather extends LitElement {
</div>
</div>
`
: nothing}
: ""}
${forecast
? html`
<div class="section">
@@ -309,90 +242,76 @@ class MoreInfoWeather extends LitElement {
)}
</mwc-tab-bar>`
: nothing}
<div class="forecast">
${forecast.map((item) =>
this._showValue(item.templow) ||
this._showValue(item.temperature)
? html`
<div>
<div>
${dayNight
? html`
${formatDateWeekdayShort(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
<div class="daynight">
${item.is_daytime !== false
? this.hass!.localize("ui.card.weather.day")
: this.hass!.localize(
"ui.card.weather.night"
)}<br />
</div>
`
: hourly
? html`
${formatTime(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
`
: html`
${formatDateWeekdayShort(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
`}
</div>
${this._showValue(item.condition)
${forecast.map((item) =>
this._showValue(item.templow) || this._showValue(item.temperature)
? html`<div class="flex">
${item.condition
? html`
<ha-svg-icon
.path=${weatherIcons[item.condition]}
></ha-svg-icon>
`
: ""}
<div class="main">
${dayNight
? html`
${formatDateWeekdayDay(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
(${item.is_daytime !== false
? this.hass!.localize("ui.card.weather.day")
: this.hass!.localize("ui.card.weather.night")})
`
: hourly
? html`
<div class="forecast-image-icon">
${getWeatherStateIcon(
item.condition!,
this,
!(
item.is_daytime ||
item.is_daytime === undefined
)
)}
</div>
${formatTimeWeekday(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
`
: nothing}
<div class="temp">
${this._showValue(item.temperature)
? html`${formatNumber(
item.temperature,
this.hass!.locale
)}°`
: "—"}
</div>
<div class="templow">
${this._showValue(item.templow)
? html`${formatNumber(
item.templow!,
this.hass!.locale
)}°`
: hourly
? nothing
: "—"}
</div>
</div>
`
: nothing
)}
</div>
: html`
${formatDateWeekdayDay(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
`}
</div>
<div class="templow">
${this._showValue(item.templow)
? this.hass.formatEntityAttributeValue(
this.stateObj!,
"templow",
item.templow
)
: hourly
? ""
: "—"}
</div>
<div class="temp">
${this._showValue(item.temperature)
? this.hass.formatEntityAttributeValue(
this.stateObj!,
"temperature",
item.temperature
)
: "—"}
</div>
</div>`
: ""
)}
`
: nothing}
: ""}
${this.stateObj.attributes.attribution
? html`
<div class="attribution">
${this.stateObj.attributes.attribution}
</div>
`
: nothing}
: ""}
`;
}
@@ -402,186 +321,56 @@ class MoreInfoWeather extends LitElement {
];
}
static get styles(): CSSResultGroup {
return [
weatherSVGStyles,
css`
ha-svg-icon {
color: var(--paper-item-icon-color);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
static styles = css`
ha-svg-icon {
color: var(--paper-item-icon-color);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
mwc-tab-bar {
margin-bottom: 4px;
}
mwc-tab-bar {
margin-bottom: 4px;
}
.section {
margin: 16px 0 8px 0;
font-size: 1.2em;
}
.section {
margin: 16px 0 8px 0;
font-size: 1.2em;
}
.flex {
display: flex;
height: 32px;
align-items: center;
}
.flex > div:last-child {
direction: ltr;
}
.flex {
display: flex;
height: 32px;
align-items: center;
}
.flex > div:last-child {
direction: ltr;
}
.main {
flex: 1;
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
}
.main {
flex: 1;
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
}
.attribution {
text-align: center;
margin-top: 16px;
}
.temp,
.templow {
min-width: 48px;
text-align: right;
direction: ltr;
}
.time-ago,
.attribute {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.templow {
margin: 0 16px;
color: var(--secondary-text-color);
}
.attribution,
.templow,
.daynight,
.attribute,
.time-ago {
color: var(--secondary-text-color);
}
.content {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.icon-image {
display: flex;
align-items: center;
min-width: 64px;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
.icon-image > * {
flex: 0 0 64px;
height: 64px;
}
.weather-icon {
--mdc-icon-size: 64px;
}
.info {
display: flex;
justify-content: space-between;
flex-grow: 1;
overflow: hidden;
}
.temp-attribute {
text-align: var(--float-end);
}
.temp-attribute .temp {
position: relative;
margin-right: 24px;
direction: ltr;
}
.temp-attribute .temp span {
position: absolute;
font-size: 24px;
top: 1px;
}
.state,
.temp-attribute .temp {
font-size: 28px;
line-height: 1.2;
}
.attribute {
font-size: 14px;
line-height: 1;
}
.name-state {
overflow: hidden;
padding-right: 12px;
padding-inline-end: 12px;
padding-inline-start: initial;
width: 100%;
}
.state {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forecast {
display: flex;
justify-content: space-around;
padding: 16px;
padding-bottom: 0px;
overflow-x: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
mask-image: linear-gradient(
90deg,
transparent 0%,
black 5%,
black 94%,
transparent 100%
);
}
.forecast > div {
text-align: center;
padding: 0 10px;
}
.forecast .icon,
.forecast .temp {
margin: 4px 0;
}
.forecast .temp {
font-size: 16px;
}
.forecast-image-icon {
padding-top: 4px;
padding-bottom: 4px;
display: flex;
justify-content: center;
}
.forecast-image-icon > * {
width: 40px;
height: 40px;
--mdc-icon-size: 40px;
}
.forecast-icon {
--mdc-icon-size: 40px;
}
`,
];
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
}
`;
private _showValue(item: number | string | undefined): boolean {
return typeof item !== "undefined" && item !== null;

View File

@@ -47,8 +47,6 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@state() private _assistConfiguration?: AssistSatelliteConfiguration;
@state() private _error?: string;
private _previousSteps: STEP[] = [];
private _nextStep?: STEP;
@@ -167,86 +165,79 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"update"
)}
></ha-voice-assistant-setup-step-update>`
: this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: assistEntityState?.state === UNAVAILABLE
? html`<ha-alert alert-type="error"
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
)}</ha-alert
>`
: this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check
: assistEntityState?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
)
: this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check
.hass=${this.hass}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-check>`
: this._step === STEP.WAKEWORD
? html`<ha-voice-assistant-setup-step-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-check>`
: this._step === STEP.WAKEWORD
? html`<ha-voice-assistant-setup-step-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
.deviceEntities=${this._deviceEntities(
this._params.deviceId,
this.hass.entities
)}
></ha-voice-assistant-setup-step-wake-word>`
: this._step === STEP.CHANGE_WAKEWORD
.deviceEntities=${this._deviceEntities(
this._params.deviceId,
this.hass.entities
)}
></ha-voice-assistant-setup-step-wake-word>`
: this._step === STEP.CHANGE_WAKEWORD
? html`
<ha-voice-assistant-setup-step-change-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-change-wake-word>
`
: this._step === STEP.AREA
? html`
<ha-voice-assistant-setup-step-change-wake-word
<ha-voice-assistant-setup-step-area
.hass=${this.hass}
.deviceId=${this._params.deviceId}
></ha-voice-assistant-setup-step-area>
`
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-change-wake-word>
`
: this._step === STEP.AREA
? html`
<ha-voice-assistant-setup-step-area
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
.hass=${this.hass}
.deviceId=${this._params.deviceId}
></ha-voice-assistant-setup-step-area>
`
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.hass=${this.hass}
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
</div>
</ha-dialog>
`;
}
private async _fetchAssistConfiguration() {
try {
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass,
this._findDomainEntityId(
this._params!.deviceId,
this.hass.entities,
"assist_satellite"
)!
);
} catch (err: any) {
this._error = err.message;
}
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass,
this._findDomainEntityId(
this._params!.deviceId,
this.hass.entities,
"assist_satellite"
)!
);
return this._assistConfiguration;
}
private _goToPreviousStep() {
@@ -302,10 +293,6 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
.skip-btn {
margin-top: 6px;
}
ha-alert {
margin: 24px;
display: block;
}
`,
];
}

View File

@@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
<div class="rows">
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
? html`<div class="row">
? html` <div class="row">
<ha-select
.label=${"Wake word"}
@closed=${stopPropagation}

View File

@@ -44,15 +44,6 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
protected override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("assistConfiguration")) {
if (
this.assistConfiguration &&
!this.assistConfiguration.available_wake_words.length
) {
this._nextStep();
}
}
if (changedProperties.has("assistEntityId")) {
this._detected = false;
this._muteSwitchEntity = this.deviceEntities?.find(
@@ -144,16 +135,13 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
>`
: nothing}
</div>
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
? html`<div class="footer centered">
<ha-button @click=${this._changeWakeWord}
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
)}</ha-button
>
</div>`
: nothing}`;
<div class="footer centered">
<ha-button @click=${this._changeWakeWord}
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
)}</ha-button
>
</div>`;
}
private async _listenWakeWord() {

View File

@@ -17,7 +17,7 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
@property({ attribute: false }) public localize: LocalizeFunc = empty;
// Use browser language setup before login.
@property() public language: string = getLocalLanguage();
@property() public language?: string = getLocalLanguage();
@property() public translationFragment?: string;

View File

@@ -41,7 +41,6 @@ import "./onboarding-analytics";
import "./onboarding-create-user";
import "./onboarding-loading";
import "./onboarding-welcome";
import "./onboarding-restore-backup";
import "./onboarding-welcome-links";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { navigate } from "../common/navigate";
@@ -158,9 +157,8 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
private _renderStep() {
if (this._restoring) {
return html`<onboarding-restore-backup
.hass=${this.hass}
.localize=${this.localize}
.supervisor=${this._supervisor ?? false}
.language=${this.language}
>
</onboarding-restore-backup>`;
}
@@ -168,6 +166,8 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (this._init) {
return html`<onboarding-welcome
.localize=${this.localize}
.language=${this.language}
.supervisor=${this._supervisor}
></onboarding-welcome>`;
}
@@ -236,7 +236,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
}
if (changedProps.has("language")) {
document.querySelector("html")!.setAttribute("lang", this.language);
document.querySelector("html")!.setAttribute("lang", this.language!);
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
@@ -272,6 +272,10 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
"Home Assistant OS",
"Home Assistant Supervised",
].includes(response.installation_type);
if (this._supervisor) {
// Only load if we have supervisor
import("./onboarding-restore-backup");
}
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(
@@ -450,7 +454,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
subscribeOne(conn, subscribeUser),
]);
this.initializeHass(auth, conn);
if (this.language !== this.hass!.language) {
if (this.language && this.language !== this.hass!.language) {
this._updateHass({
locale: { ...this.hass!.locale, language: this.language },
language: this.language,

View File

@@ -1,336 +1,136 @@
import type { TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "./restore-backup/onboarding-restore-backup-upload";
import "./restore-backup/onboarding-restore-backup-details";
import "./restore-backup/onboarding-restore-backup-restore";
import "./restore-backup/onboarding-restore-backup-status";
import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup";
import "../../hassio/src/components/hassio-upload-backup";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-ansi-to-html";
import "../components/ha-card";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-circular-progress";
import "../components/ha-alert";
import "../components/ha-button";
import { fetchInstallationType } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import "./onboarding-loading";
import { onBoardingStyles } from "./styles";
import { removeSearchParam } from "../common/url/search-params";
import { navigate } from "../common/navigate";
import { onBoardingStyles } from "./styles";
import {
fetchBackupOnboardingInfo,
type BackupOnboardingConfig,
type BackupOnboardingInfo,
} from "../data/backup_onboarding";
import type { BackupContentExtended, BackupData } from "../data/backup";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { storage } from "../common/decorators/storage";
const STATUS_INTERVAL_IN_MS = 5000;
@customElement("onboarding-restore-backup")
class OnboardingRestoreBackup extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property() public language!: string;
@property({ type: Boolean }) public supervisor = false;
@state() private _restoring = false;
@state() private _view:
| "loading"
| "upload"
| "select_data"
| "confirm_restore"
| "status" = "loading";
@state() private _backup?: BackupContentExtended;
@state() private _backupInfo?: BackupOnboardingInfo;
@state() private _selectedData?: BackupData;
@state() private _error?: string;
@state() private _failed?: boolean;
@storage({
key: "onboarding-restore-backup-backup-id",
})
private _backupId?: string;
@storage({
key: "onboarding-restore-running",
})
private _restoreRunning?: boolean;
@state() private _backupSlug?: string;
protected render(): TemplateResult {
return html`
${
this._view !== "status" || this._failed
? html`<ha-icon-button-arrow-prev
.label=${this.localize("ui.panel.page-onboarding.restore.back")}
@click=${this._back}
></ha-icon-button-arrow-prev>`
: nothing
}
</ha-icon-button>
<h1>${this.localize("ui.panel.page-onboarding.restore.header")}</h1>
${
this._error || (this._failed && this._view !== "status")
? html`<ha-alert
alert-type="error"
.title=${this._failed && this._view !== "status"
? this.localize("ui.panel.page-onboarding.restore.failed")
: ""}
>
${this._failed && this._view !== "status"
? this.localize(
`ui.panel.page-onboarding.restore.${this._backupInfo?.last_non_idle_event?.reason === "password_incorrect" ? "failed_wrong_password_description" : "failed_description"}`
)
: this._error}
</ha-alert>`
: nothing
}
${
this._view === "loading"
? html`<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>`
: this._view === "upload"
? html`
<onboarding-restore-backup-upload
.supervisor=${this.supervisor}
.localize=${this.localize}
@backup-uploaded=${this._backupUploaded}
></onboarding-restore-backup-upload>
`
: this._view === "select_data"
? html`<onboarding-restore-backup-details
.localize=${this.localize}
.backup=${this._backup!}
@backup-restore=${this._restore}
></onboarding-restore-backup-details>`
: this._view === "confirm_restore"
? html`<onboarding-restore-backup-restore
.localize=${this.localize}
.backup=${this._backup!}
.supervisor=${this.supervisor}
.selectedData=${this._selectedData!}
@restore-started=${this._restoreStarted}
></onboarding-restore-backup-restore>`
: nothing
}
${
this._view === "status" && this._backupInfo
? html`<onboarding-restore-backup-status
${this._restoring
? html`<h1>
${this.localize("ui.panel.page-onboarding.restore.in_progress")}
</h1>
<ha-alert alert-type="info">
${this.localize("ui.panel.page-onboarding.restore.in_progress")}
</ha-alert>
<onboarding-loading></onboarding-loading>`
: html` <h1>
${this.localize("ui.panel.page-onboarding.restore.header")}
</h1>
<hassio-upload-backup
@backup-uploaded=${this._backupUploaded}
@backup-cleared=${this._backupCleared}
.hass=${this.hass}
.localize=${this.localize}
.backupInfo=${this._backupInfo}
@show-backup-upload=${this._reupload}
></onboarding-restore-backup-status>`
: nothing
}
${
["select_data", "confirm_restore"].includes(this._view) && this._backup
? html`<div class="backup-summary-wrapper">
<ha-backup-details-summary
translation-key-panel="page-onboarding.restore"
show-upload-another
.backup=${this._backup}
.localize=${this.localize}
@show-backup-upload=${this._reupload}
.isHassio=${this.supervisor}
></ha-backup-details-summary>
</div>`
: nothing
}
></hassio-upload-backup>`}
<div class="footer">
<ha-button @click=${this._back} .disabled=${this._restoring}>
${this.localize("ui.panel.page-onboarding.back")}
</ha-button>
${this._backupSlug
? html`<ha-button
@click=${this._showBackupDialog}
.disabled=${this._restoring}
>
${this.localize("ui.panel.page-onboarding.restore.restore")}
</ha-button>`
: nothing}
</div>
`;
}
private _back(): void {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
private _backupUploaded(ev) {
const backup = ev.detail.backup;
this._backupSlug = backup.slug;
this._showBackupDialog();
}
private _backupCleared() {
this._backupSlug = undefined;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._loadBackupInfo();
}
private async _loadBackupInfo() {
let onboardingInfo: BackupOnboardingConfig;
try {
onboardingInfo = await fetchBackupOnboardingInfo();
} catch (err: any) {
if (this._restoreRunning) {
private async _checkRestoreStatus(): Promise<void> {
if (this._restoring) {
try {
await fetchInstallationType();
} catch (err: any) {
if (
err.error === "Request error" ||
// core can restart but haven't loaded the backup integration yet
(err.status_code === 500 && err.body?.error === "backup_disabled")
(err as Error).message === "unauthorized" ||
(err as Error).message === "not_found"
) {
// core is down because of restore, keep trying
this._scheduleLoadBackupInfo();
return;
}
// core seems to be back up restored
if (err.status_code === 404) {
this._restoreRunning = undefined;
this._backupId = undefined;
window.location.replace("/");
return;
}
}
this._error = err?.message || "Cannot get backup info";
// if we are in an unknown state, show upload
if (this._view === "loading") {
this._view = "upload";
}
return;
}
const {
last_non_idle_event: lastNonIdleEvent,
state: currentState,
backups,
} = onboardingInfo;
this._backupInfo = {
state: currentState,
last_non_idle_event: lastNonIdleEvent,
};
if (this._backupId) {
this._backup = backups.find(
({ backup_id }) => backup_id === this._backupId
);
}
const failedRestore =
lastNonIdleEvent?.manager_state === "restore_backup" &&
lastNonIdleEvent?.state === "failed";
if (failedRestore) {
this._failed = true;
}
if (this._restoreRunning) {
this._view = "status";
if (failedRestore || currentState !== "restore_backup") {
this._failed = true;
this._restoreRunning = undefined;
} else {
this._scheduleLoadBackupInfo();
}
return;
}
if (
this._backup &&
// after backup was uploaded
(lastNonIdleEvent?.manager_state === "receive_backup" ||
// when restore was confirmed but failed to start (for example, encryption key was wrong)
failedRestore)
) {
if (!this.supervisor && this._backup.homeassistant_included) {
this._selectedData = {
homeassistant_included: true,
folders: [],
addons: [],
homeassistant_version: this._backup.homeassistant_version,
database_included: this._backup.database_included,
};
// skip select data when supervisor is not available and backup includes HA
this._view = "confirm_restore";
} else {
this._view = "select_data";
}
return;
}
// show upload as default
this._view = "upload";
}
private _scheduleLoadBackupInfo() {
setTimeout(() => this._loadBackupInfo(), STATUS_INTERVAL_IN_MS);
}
private async _backupUploaded(ev: CustomEvent) {
this._backupId = ev.detail.backupId;
await this._loadBackupInfo();
}
private async _restoreStarted() {
if (this._backupInfo) {
this._backupInfo.state = "restore_backup";
}
this._view = "status";
this._restoreRunning = true;
await this._loadBackupInfo();
}
private async _back() {
if (this._view === "upload" || (this._view === "status" && this._failed)) {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
} else {
const confirmed = await showConfirmationDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.title"
),
text: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.text"
),
confirmText: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.yes"
),
dismissText: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.no"
),
});
if (!confirmed) {
return;
}
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
}
private _restore(ev: CustomEvent) {
if (!this._backup || !ev.detail.selectedData) {
return;
}
this._selectedData = ev.detail.selectedData;
this._view = "confirm_restore";
private _scheduleCheckRestoreStatus(): void {
setTimeout(() => this._checkRestoreStatus(), 1000);
}
private _reupload() {
this._backup = undefined;
this._backupId = undefined;
this._view = "upload";
private _showBackupDialog(): void {
showHassioBackupDialog(this, {
slug: this._backupSlug!,
onboarding: true,
localize: this.localize,
onRestoring: () => {
this._restoring = true;
this._scheduleCheckRestoreStatus();
},
});
}
static styles = [
onBoardingStyles,
css`
:host {
display: flex;
flex-direction: column;
position: relative;
}
ha-icon-button-arrow-prev {
position: absolute;
top: 12px;
}
ha-card {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
padding: 32px;
}
.backup-summary-wrapper {
margin-top: 24px;
padding: 0 20px;
}
`,
];
static get styles(): CSSResultGroup {
return [
onBoardingStyles,
css`
:host {
display: flex;
flex-direction: column;
align-items: center;
}
hassio-upload-backup {
width: 100%;
}
.footer {
display: flex;
justify-content: space-between;
width: 100%;
}
`,
];
}
}
declare global {

View File

@@ -1,5 +1,5 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
@@ -13,6 +13,8 @@ class OnboardingWelcome extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Boolean }) public supervisor = false;
protected render(): TemplateResult {
return html`
<h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1>
@@ -22,9 +24,11 @@ class OnboardingWelcome extends LitElement {
${this.localize("ui.panel.page-onboarding.welcome.start")}
</ha-button>
<ha-button @click=${this._restoreBackup}>
${this.localize("ui.panel.page-onboarding.welcome.restore_backup")}
</ha-button>
${this.supervisor
? html`<ha-button @click=${this._restoreBackup}>
${this.localize("ui.panel.page-onboarding.welcome.restore_backup")}
</ha-button>`
: nothing}
`;
}

View File

@@ -1,57 +0,0 @@
import { css, html, LitElement, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-circular-progress";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../panels/config/backup/components/ha-backup-details-restore";
import "../../panels/config/backup/components/ha-backup-details-summary";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupContentExtended } from "../../data/backup";
@customElement("onboarding-restore-backup-details")
class OnboardingRestoreBackupDetails extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
render() {
return html`
${this.backup.homeassistant_included
? html`<ha-backup-details-restore
.backup=${this.backup}
.localize=${this.localize}
translation-key-panel="page-onboarding.restore"
ha-required
></ha-backup-details-restore>`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.home_assistant_missing"
)}
</ha-alert>
`}
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
ha-backup-details-restore {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-details": OnboardingRestoreBackupDetails;
}
}

View File

@@ -1,175 +0,0 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-alert";
import "../../components/buttons/ha-progress-button";
import "../../components/ha-password-field";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
type BackupContentExtended,
type BackupData,
} from "../../data/backup";
import { restoreOnboardingBackup } from "../../data/backup_onboarding";
import type { HaProgressButton } from "../../components/buttons/ha-progress-button";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("onboarding-restore-backup-restore")
class OnboardingRestoreBackupRestore extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
@property({ attribute: false })
public selectedData!: BackupData;
@property({ type: Boolean }) public supervisor = false;
@state() private _encryptionKey = "";
@state() private _encryptionKeyWrong = false;
@state() private _error?: string;
@state() private _loading = false;
render() {
const agentId = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT;
const backupProtected = this.backup.agents[agentId].protected;
return html`
${this.backup.homeassistant_included &&
!this.supervisor &&
(this.backup.addons.length > 0 || this.backup.folders.length > 0)
? html`<ha-alert alert-type="warning" class="supervisor-warning">
${this.localize(
"ui.panel.page-onboarding.restore.details.addons_unsupported"
)}
</ha-alert>`
: nothing}
<ha-card
.header=${this.localize("ui.panel.page-onboarding.restore.restore")}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert> `
: nothing}
<p>
${this.localize(
"ui.panel.page-onboarding.restore.confirm_restore_full_backup_text"
)}
</p>
${backupProtected
? html`<p>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.title"
)}
</p>
${this._encryptionKeyWrong
? html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)}
</ha-alert>
`
: nothing}
<ha-password-field
.disabled=${this._loading}
@input=${this._encryptionKeyChanged}
.label=${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.input_label"
)}
.value=${this._encryptionKey}
></ha-password-field>`
: nothing}
</div>
<div class="card-actions">
<ha-progress-button
.progress=${this._loading}
.disabled=${this._loading ||
(backupProtected && this._encryptionKey === "")}
@click=${this._startRestore}
destructive
>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.action"
)}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _encryptionKeyChanged(ev): void {
this._encryptionKey = ev.target.value;
}
private async _startRestore(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as HaProgressButton;
this._loading = true;
this._error = undefined;
this._encryptionKeyWrong = false;
const backupAgent = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT;
try {
await restoreOnboardingBackup({
agent_id: backupAgent,
backup_id: this.backup.backup_id,
password: this._encryptionKey || undefined,
restore_addons: this.selectedData.addons.map((addon) => addon.slug),
restore_database: this.selectedData.database_included,
restore_folders: this.selectedData.folders,
});
button.actionSuccess();
fireEvent(this, "restore-started");
} catch (err: any) {
if (err.error === "Request error") {
// core can shutdown before we get a response
button.actionSuccess();
fireEvent(this, "restore-started");
return;
}
button.actionError();
if (err.body?.code === "incorrect_password") {
this._encryptionKeyWrong = true;
} else {
this._error =
err.body?.message || err.message || "Unknown error occurred";
}
this._loading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.supervisor-warning {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-restore": OnboardingRestoreBackupRestore;
}
interface HASSDomEvents {
"restore-started";
}
}

View File

@@ -1,119 +0,0 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-circular-progress";
import "../../components/ha-alert";
import "../../components/ha-button";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupOnboardingInfo } from "../../data/backup_onboarding";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { removeSearchParam } from "../../common/url/search-params";
@customElement("onboarding-restore-backup-status")
class OnboardingRestoreBackupStatus extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false })
public backupInfo!: BackupOnboardingInfo;
render() {
return html`
<ha-card
.header=${this.localize(
`ui.panel.page-onboarding.restore.${this.backupInfo.state === "restore_backup" ? "in_progress" : "failed"}`
)}
>
<div class="card-content">
${this.backupInfo.state === "restore_backup"
? html`
<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
<p>
${this.localize(
"ui.panel.page-onboarding.restore.in_progress_description"
)}
</p>
`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.failed_status_description"
)}
</ha-alert>
${this.backupInfo.last_non_idle_event?.reason
? html`
<div class="failed">
<h4>Error:</h4>
${this.backupInfo.last_non_idle_event?.reason}
</div>
`
: nothing}
`}
</div>
${this.backupInfo.state !== "restore_backup"
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
<ha-button @click=${this._home} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.home`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
private _uploadAnother() {
fireEvent(this, "show-backup-upload");
}
private _home() {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.loading {
display: flex;
justify-content: center;
padding: 32px;
}
p {
text-align: center;
padding: 0 16px;
font-size: 16px;
}
.failed {
padding: 16px 0;
font-size: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-status": OnboardingRestoreBackupStatus;
}
interface HASSDomEvents {
"restore-started";
}
}

View File

@@ -1,126 +0,0 @@
import { mdiFolderUpload } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-file-upload";
import "../../components/ha-alert";
import { haStyle } from "../../resources/styles";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
import { uploadOnboardingBackup } from "../../data/backup_onboarding";
declare global {
interface HASSDomEvents {
"backup-uploaded": { backupId: string };
}
}
@customElement("onboarding-restore-backup-upload")
class OnboardingRestoreBackupUpload extends LitElement {
@property({ type: Boolean }) public supervisor = false;
@property({ attribute: false }) public localize!: LocalizeFunc;
@state() private _uploading = false;
@state() private _error?: string;
render() {
return html`
<ha-card
.header=${this.localize(
"ui.panel.page-onboarding.restore.upload_backup"
)}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.localize}
.label=${this.localize(
"ui.panel.page-onboarding.restore.upload_input_label"
)}
.secondary=${this.localize(
"ui.panel.page-onboarding.restore.upload_secondary"
)}
.supports=${this.localize(
"ui.panel.page-onboarding.restore.upload_supports_tar"
)}
.deleteLabel=${this.localize(
"ui.panel.page-onboarding.restore.delete"
)}
.uploadingLabel=${this.localize(
"ui.panel.page-onboarding.restore.uploading"
)}
@file-picked=${this._filePicked}
></ha-file-upload>
</div>
</ha-card>
`;
}
private async _filePicked(ev: HASSDomEvent<{ files: File[] }>) {
this._error = undefined;
const file = ev.detail.files[0];
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.unsupported.title"
),
text: this.localize(
"ui.panel.page-onboarding.restore.unsupported.text"
),
confirmText: this.localize("ui.panel.page-onboarding.restore.ok"),
});
return;
}
const agentIds = this.supervisor
? [HASSIO_LOCAL_AGENT]
: [CORE_LOCAL_AGENT];
this._uploading = true;
try {
const { backup_id } = await uploadOnboardingBackup(file, agentIds);
fireEvent(this, "backup-uploaded", { backupId: backup_id });
} catch (err: any) {
this._error =
typeof err.body === "string"
? err.body
: err.body?.message || err.message || "Unknown error occurred";
} finally {
this._uploading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
width: 100%;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-upload": OnboardingRestoreBackupUpload;
}
}

View File

@@ -106,7 +106,6 @@ export class HaConfigApplicationCredentials extends LitElement {
},
actions: {
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
hideable: false,

View File

@@ -329,9 +329,6 @@ class DialogAreaDetail extends LitElement {
return [
haStyleDialog,
css`
ha-textfield {
display: block;
}
ha-aliases-editor,
ha-entity-picker,
ha-floor-picker,

View File

@@ -1,4 +1,5 @@
import { consume } from "@lit-labs/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiAlertCircleCheck,
mdiArrowDown,
@@ -26,9 +27,7 @@ import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
@@ -241,104 +240,89 @@ export default class HaAutomationActionRow extends LitElement {
</div> `
: nothing}
<ha-md-button-menu
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._runAction}>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.run"
)}
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiPlay}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._renameAction}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<li divider role="separator"></li>
<ha-md-menu-item
.clickAction=${this._duplicateAction}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="start"
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._copyAction}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._cutAction}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon
></ha-list-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon
></ha-list-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this._uiModeAvailable}
>
<ha-list-item graphic="icon" .disabled=${!this._uiModeAvailable}>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<li divider role="separator"></li>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.action.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
@@ -347,15 +331,15 @@ export default class HaAutomationActionRow extends LitElement {
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
slot="graphic"
.path=${this.action.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
</ha-list-item>
<ha-list-item
class="warning"
.clickAction=${this._onDelete}
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -363,11 +347,11 @@ export default class HaAutomationActionRow extends LitElement {
)}
<ha-svg-icon
class="warning"
slot="start"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-list-item>
</ha-button-menu>
<div
class=${classMap({
@@ -440,6 +424,47 @@ export default class HaAutomationActionRow extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._runAction();
break;
case 1:
await this._renameAction();
break;
case 2:
fireEvent(this, "duplicate");
break;
case 3:
this._setClipboard();
break;
case 4:
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
break;
case 5:
fireEvent(this, "move-up");
break;
case 6:
fireEvent(this, "move-down");
break;
case 7:
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
break;
case 8:
this._onDisable();
break;
case 9:
this._onDelete();
break;
}
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -447,16 +472,16 @@ export default class HaAutomationActionRow extends LitElement {
};
}
private _onDisable = () => {
private _onDisable() {
const enabled = !(this.action.enabled ?? true);
const value = { ...this.action, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
};
}
private _runAction = async () => {
private async _runAction() {
const validated = await validateConfig(this.hass, {
actions: this.action,
});
@@ -488,9 +513,9 @@ export default class HaAutomationActionRow extends LitElement {
"ui.panel.config.automation.editor.actions.run_action_success"
),
});
};
}
private _onDelete = () => {
private _onDelete() {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete_confirm_title"
@@ -505,7 +530,7 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "value-changed", { value: null });
},
});
};
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
@@ -536,7 +561,7 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlMode = true;
}
private _renameAction = async (): Promise<void> => {
private async _renameAction(): Promise<void> {
const alias = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.change_alias"
@@ -573,37 +598,7 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlEditor?.setValue(value);
}
}
};
private _duplicateAction = () => {
fireEvent(this, "duplicate");
};
private _copyAction = () => {
this._setClipboard();
};
private _cutAction = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
};
private _moveUp = () => {
fireEvent(this, "move-up");
};
private _moveDown = () => {
fireEvent(this, "move-down");
};
private _toggleYamlMode = () => {
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
};
}
public expand() {
this.updateComplete.then(() => {
@@ -615,6 +610,7 @@ export default class HaAutomationActionRow extends LitElement {
return [
haStyle,
css`
ha-button-menu,
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
@@ -653,11 +649,18 @@ export default class HaAutomationActionRow extends LitElement {
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
}
mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
mwc-list-item.hidden {
display: none;
}
.warning ul {
margin: 4px 0;
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
li[role="separator"] {
border-bottom-color: var(--divider-color);
}
`,
];

View File

@@ -11,16 +11,17 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stringCompare } from "../../../common/string/compare";
import { stripDiacritics } from "../../../common/string/strip-diacritics";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import "../../../components/ha-dialog";
import type { HaDialog } from "../../../components/ha-dialog";
import "../../../components/ha-dialog-header";
import "../../../components/ha-md-divider";
import "../../../components/ha-domain-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-service-icon";
@@ -44,6 +45,7 @@ import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { getStripDiacriticsFn } from "../../../util/fuse";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
@@ -200,10 +202,10 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
ignoreLocation: true,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: getStripDiacriticsFn,
};
const fuse = new Fuse(items, options);
return fuse.search(filter).map((result) => result.item);
return fuse.search(stripDiacritics(filter)).map((result) => result.item);
}
);

View File

@@ -1,4 +1,5 @@
import { consume } from "@lit-labs/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiArrowDown,
mdiArrowUp,
@@ -23,12 +24,11 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import type {
AutomationClipboard,
Condition,
@@ -141,12 +141,12 @@ export default class HaAutomationConditionRow extends LitElement {
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
fixed
>
<ha-icon-button
slot="trigger"
@@ -155,91 +155,76 @@ export default class HaAutomationConditionRow extends LitElement {
>
</ha-icon-button>
<ha-md-menu-item .clickAction=${this._testCondition}>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
)}
<ha-svg-icon slot="start" .path=${mdiFlask}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._renameCondition}
.disabled=${this.disabled}
>
<ha-svg-icon slot="graphic" .path=${mdiFlask}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<li divider role="separator"></li>
<ha-md-menu-item
.clickAction=${this._duplicateCondition}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="start"
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._copyCondition}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._cutCondition}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon
></ha-list-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon
></ha-list-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${this._warnings}
>
<ha-list-item graphic="icon" .disabled=${this._warnings}>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<li divider role="separator"></li>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.condition.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
@@ -248,15 +233,15 @@ export default class HaAutomationConditionRow extends LitElement {
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
slot="graphic"
.path=${this.condition.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
</ha-list-item>
<ha-list-item
class="warning"
.clickAction=${this._onDelete}
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -264,11 +249,11 @@ export default class HaAutomationConditionRow extends LitElement {
)}
<ha-svg-icon
class="warning"
slot="start"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-list-item>
</ha-button-menu>
<div
class=${classMap({
@@ -340,6 +325,47 @@ export default class HaAutomationConditionRow extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
await this._testCondition();
break;
case 1:
await this._renameCondition();
break;
case 2:
fireEvent(this, "duplicate");
break;
case 3:
this._setClipboard();
break;
case 4:
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
break;
case 5:
fireEvent(this, "move-up");
break;
case 6:
fireEvent(this, "move-down");
break;
case 7:
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
break;
case 8:
this._onDisable();
break;
case 9:
this._onDelete();
break;
}
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -347,13 +373,13 @@ export default class HaAutomationConditionRow extends LitElement {
};
}
private _onDisable = () => {
private _onDisable() {
const enabled = !(this.condition.enabled ?? true);
const value = { ...this.condition, enabled };
fireEvent(this, "value-changed", { value });
};
}
private _onDelete = () => {
private _onDelete() {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.delete_confirm_title"
@@ -368,7 +394,7 @@ export default class HaAutomationConditionRow extends LitElement {
fireEvent(this, "value-changed", { value: null });
},
});
};
}
private _switchUiMode() {
this._warnings = undefined;
@@ -380,7 +406,7 @@ export default class HaAutomationConditionRow extends LitElement {
this._yamlMode = true;
}
private _testCondition = async () => {
private async _testCondition() {
if (this._testing) {
return;
}
@@ -435,9 +461,9 @@ export default class HaAutomationConditionRow extends LitElement {
this._testing = false;
}, 2500);
}
};
}
private _renameCondition = async (): Promise<void> => {
private async _renameCondition(): Promise<void> {
const alias = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.change_alias"
@@ -463,37 +489,7 @@ export default class HaAutomationConditionRow extends LitElement {
value,
});
}
};
private _duplicateCondition = () => {
fireEvent(this, "duplicate");
};
private _copyCondition = () => {
this._setClipboard();
};
private _cutCondition = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
};
private _moveUp = () => {
fireEvent(this, "move-up");
};
private _moveDown = () => {
fireEvent(this, "move-down");
};
private _toggleYamlMode = () => {
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
};
}
public expand() {
this.updateComplete.then(() => {
@@ -505,6 +501,9 @@ export default class HaAutomationConditionRow extends LitElement {
return [
haStyle,
css`
ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
@@ -540,6 +539,12 @@ export default class HaAutomationConditionRow extends LitElement {
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);
}
ha-list-item.hidden {
display: none;
}
.testing {
position: absolute;
top: 0px;
@@ -566,8 +571,8 @@ export default class HaAutomationConditionRow extends LitElement {
.testing.pass {
background-color: var(--success-color);
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
li[role="separator"] {
border-bottom-color: var(--divider-color);
}
`,
];

View File

@@ -339,8 +339,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
sortable: true,
groupable: true,
hidden: narrow,
title: "",
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
label: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-entity-toggle
.stateObj=${automation}

View File

@@ -1,4 +1,5 @@
import { consume } from "@lit-labs/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiArrowDown,
mdiArrowUp,
@@ -27,9 +28,7 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import { handleStructError } from "../../../../common/structs/handle-errors";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
@@ -170,12 +169,12 @@ export default class HaAutomationTriggerRow extends LitElement {
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
fixed
>
<ha-icon-button
slot="trigger"
@@ -183,93 +182,84 @@ export default class HaAutomationTriggerRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this._renameTrigger}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._showTriggerId}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiIdentifier}></ha-svg-icon>
</ha-list-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<li divider role="separator"></li>
<ha-md-menu-item
.clickAction=${this._duplicateTrigger}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate"
)}
<ha-svg-icon
slot="start"
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._copyTrigger}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._cutTrigger}
.disabled=${this.disabled}
>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon
></ha-list-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon
></ha-list-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!supported}
>
<ha-list-item graphic="icon" .disabled=${!supported}>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<li divider role="separator"></li>
<ha-md-menu-item
.clickAction=${this._onDisable}
<ha-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${"enabled" in this.trigger && this.trigger.enabled === false
@@ -280,16 +270,16 @@ export default class HaAutomationTriggerRow extends LitElement {
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
slot="graphic"
.path=${"enabled" in this.trigger &&
this.trigger.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
</ha-list-item>
<ha-list-item
class="warning"
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -297,11 +287,11 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
<ha-svg-icon
class="warning"
slot="start"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-list-item>
</ha-button-menu>
<div
class=${classMap({
@@ -474,6 +464,48 @@ export default class HaAutomationTriggerRow extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
await this._renameTrigger();
break;
case 1:
this._requestShowId = true;
this.expand();
break;
case 2:
fireEvent(this, "duplicate");
break;
case 3:
this._setClipboard();
break;
case 4:
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
break;
case 5:
fireEvent(this, "move-up");
break;
case 6:
fireEvent(this, "move-down");
break;
case 7:
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
break;
case 8:
this._onDisable();
break;
case 9:
this._onDelete();
break;
}
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -481,7 +513,7 @@ export default class HaAutomationTriggerRow extends LitElement {
};
}
private _onDelete = () => {
private _onDelete() {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.triggers.delete_confirm_title"
@@ -496,9 +528,9 @@ export default class HaAutomationTriggerRow extends LitElement {
fireEvent(this, "value-changed", { value: null });
},
});
};
}
private _onDisable = () => {
private _onDisable() {
if (isTriggerList(this.trigger)) return;
const enabled = !(this.trigger.enabled ?? true);
const value = { ...this.trigger, enabled };
@@ -506,7 +538,7 @@ export default class HaAutomationTriggerRow extends LitElement {
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
};
}
private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
@@ -573,7 +605,7 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _renameTrigger = async (): Promise<void> => {
private async _renameTrigger(): Promise<void> {
if (isTriggerList(this.trigger)) return;
const alias = await showPromptDialog(this, {
title: this.hass.localize(
@@ -604,42 +636,7 @@ export default class HaAutomationTriggerRow extends LitElement {
this._yamlEditor?.setValue(value);
}
}
};
private _showTriggerId = () => {
this._requestShowId = true;
this.expand();
};
private _duplicateTrigger = () => {
fireEvent(this, "duplicate");
};
private _copyTrigger = () => {
this._setClipboard();
};
private _cutTrigger = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
};
private _moveUp = () => {
fireEvent(this, "move-up");
};
private _moveDown = () => {
fireEvent(this, "move-down");
};
private _toggleYamlMode = () => {
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
};
}
public expand() {
this.updateComplete.then(() => {
@@ -651,6 +648,9 @@ export default class HaAutomationTriggerRow extends LitElement {
return [
haStyle,
css`
ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
@@ -714,12 +714,18 @@ export default class HaAutomationTriggerRow extends LitElement {
background-color: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color));
}
ha-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-list-item.hidden {
display: none;
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
li[role="separator"] {
border-bottom-color: var(--divider-color);
}
`,
];

View File

@@ -1,10 +1,9 @@
import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
import { mdiCog, mdiHarddisk, mdiNas } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
@@ -23,6 +22,7 @@ import {
import type { CloudStatus } from "../../../../../data/cloud";
import type { HomeAssistant } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
import { navigate } from "../../../../../common/navigate";
const DEFAULT_AGENTS = [];
@@ -41,6 +41,13 @@ class HaBackupConfigAgents extends LitElement {
@state() private value?: string[];
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private get _value() {
return this.value ?? DEFAULT_AGENTS;
}
@@ -79,84 +86,19 @@ class HaBackupConfigAgents extends LitElement {
return "";
}
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private _unavailableAgents = memoizeOne(
(
agents: BackupAgent[],
cloudStatus: CloudStatus,
selectedAgentIds: string[]
) => {
const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
(agent) => agent.agent_id
);
return selectedAgentIds
.filter((agent) => !availableAgentIds.includes(agent))
.map<BackupAgent>((id) => ({
agent_id: id,
name: id.split(".")[1] || id, // Use the id as name as it is not available in the list
}));
}
);
private _renderAgentIcon(agentId: string) {
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon .path=${mdiHarddisk} slot="start"></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`<ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon>`;
}
const domain = computeDomain(agentId);
return html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`;
}
protected render() {
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
const unavailableAgents = this._unavailableAgents(
this.agents,
this.cloudStatus,
this._value
);
const allAgents = [...availableAgents, ...unavailableAgents];
const agents = this._availableAgents(this.agents, this.cloudStatus);
return html`
${allAgents.length > 0
${agents.length > 0
? html`
<ha-md-list>
${availableAgents.map((agent) => {
${agents.map((agent) => {
const agentId = agent.agent_id;
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
this.agents
);
const description = this._description(agentId);
const noCloudSubscription =
@@ -166,7 +108,32 @@ class HaBackupConfigAgents extends LitElement {
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
${isLocalAgent(agentId)
? html`
<ha-svg-icon .path=${mdiHarddisk} slot="start">
</ha-svg-icon>
`
: isNetworkMountAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiNas}
slot="start"
></ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
<div slot="headline" class="name">${name}</div>
${description
? html`<div slot="supporting-text">${description}</div>`
@@ -184,44 +151,14 @@ class HaBackupConfigAgents extends LitElement {
<ha-switch
slot="end"
id=${agentId}
.checked=${this._value.includes(agentId)}
.disabled=${noCloudSubscription &&
!this._value.includes(agentId)}
.checked=${!noCloudSubscription &&
this._value.includes(agentId)}
.disabled=${noCloudSubscription}
@change=${this._agentToggled}
></ha-switch>
</ha-md-list-item>
`;
})}
${unavailableAgents.length > 0 && this.showSettings
? html`
<p class="heading">
${this.hass.localize(
"ui.panel.config.backup.agents.unavailable_agents"
)}
</p>
${unavailableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
);
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
<ha-icon-button
id=${agentId}
slot="end"
path=${mdiDelete}
@click=${this._deleteAgent}
></ha-icon-button>
</ha-md-list-item>
`;
})}
`
: nothing}
</ha-md-list>
`
: html`
@@ -237,13 +174,6 @@ class HaBackupConfigAgents extends LitElement {
navigate(`/config/backup/location/${agentId}`);
}
private _deleteAgent(ev): void {
ev.stopPropagation();
const agentId = ev.currentTarget.id;
this.value = this._value.filter((agent) => agent !== agentId);
fireEvent(this, "value-changed", { value: this.value });
}
private _agentToggled(ev) {
ev.stopPropagation();
const value = ev.currentTarget.checked;
@@ -255,8 +185,19 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId);
}
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
// 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((id) => availableAgents.some((agent) => agent.agent_id === id))
.filter(
(id) =>
id !== CLOUD_AGENT ||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
);
fireEvent(this, "value-changed", { value: this.value });
}

View File

@@ -378,9 +378,8 @@ class HaBackupConfigData extends LitElement {
}
@media all and (max-width: 450px) {
ha-md-select {
min-width: 140px;
width: 140px;
--md-filled-field-content-space: 0;
min-width: 160px;
width: 160px;
}
}
`;

View File

@@ -46,7 +46,7 @@ enum BackupScheduleTime {
}
interface RetentionData {
type: "copies" | "days" | "forever";
type: "copies" | "days";
value: number;
}
@@ -55,7 +55,7 @@ const RETENTION_PRESETS: Record<
RetentionData
> = {
copies_3: { type: "copies", value: 3 },
forever: { type: "forever", value: 0 },
forever: { type: "days", value: 0 },
};
const SCHEDULE_OPTIONS = [
@@ -79,10 +79,7 @@ const computeRetentionPreset = (
data: RetentionData
): RetentionPreset | undefined => {
for (const [key, value] of Object.entries(RETENTION_PRESETS)) {
if (
value.type === data.type &&
(value.type === RetentionPreset.FOREVER || value.value === data.value)
) {
if (value.type === data.type && value.value === data.value) {
return key as RetentionPreset;
}
}
@@ -95,7 +92,7 @@ interface FormData {
time?: string | null;
days: BackupDay[];
retention: {
type: "copies" | "days" | "forever";
type: "copies" | "days";
value: number;
};
}
@@ -145,12 +142,7 @@ class HaBackupConfigSchedule extends LitElement {
? config.schedule.days
: [],
retention: {
type:
config.retention.days === null && config.retention.copies === null
? "forever"
: config.retention.days != null
? "days"
: "copies",
type: config.retention.days != null ? "days" : "copies",
value: config.retention.days ?? config.retention.copies ?? 3,
},
};
@@ -168,11 +160,9 @@ class HaBackupConfigSchedule extends LitElement {
: [],
},
retention:
data.retention.type === "forever"
? { days: null, copies: null }
: data.retention.type === "days"
? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null },
data.retention.type === "days"
? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null },
};
fireEvent(this, "value-changed", { value: this.value });
@@ -413,11 +403,11 @@ class HaBackupConfigSchedule extends LitElement {
backup_create: html`<a
href=${documentationUrl(
this.hass,
"/integrations/backup/#action-backupcreate_automatic"
"/integrations/backup#example-backing-up-every-night-at-300-am"
)}
target="_blank"
rel="noopener noreferrer"
>backup.create_automatic</a
>backup.create</a
>`,
})}</ha-tip
>
@@ -491,19 +481,9 @@ class HaBackupConfigSchedule extends LitElement {
private _retentionPresetChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
let value = target.value as RetentionPreset;
// custom needs to have a type of days or copies, set it to default copies 3
if (
value === RetentionPreset.CUSTOM &&
this._retentionPreset === RetentionPreset.FOREVER
) {
this._retentionPreset = value;
value = RetentionPreset.COPIES_3;
} else {
this._retentionPreset = value;
}
const value = target.value as RetentionPreset;
this._retentionPreset = value;
if (value !== RetentionPreset.CUSTOM) {
const data = this._getData(this.value);
const retention = RETENTION_PRESETS[value];
@@ -513,7 +493,7 @@ class HaBackupConfigSchedule extends LitElement {
}
this._setData({
...data,
retention,
retention: RETENTION_PRESETS[value],
});
}
}
@@ -524,7 +504,6 @@ class HaBackupConfigSchedule extends LitElement {
const value = parseInt(target.value);
const clamped = clamp(value, MIN_VALUE, MAX_VALUE);
const data = this._getData(this.value);
target.value = clamped.toString();
this._setData({
...data,
retention: {
@@ -558,22 +537,14 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-select {
ha-md-select,
ha-time-input {
min-width: 210px;
}
ha-time-input {
min-width: 194px;
--time-input-flex: 1;
}
@media all and (max-width: 450px) {
ha-md-select {
min-width: 160px;
width: 160px;
--md-filled-field-content-space: 0;
}
ha-md-select,
ha-time-input {
min-width: 145px;
width: 145px;
min-width: 160px;
}
}
ha-md-textfield#value {
@@ -582,16 +553,6 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type {
min-width: 100px;
}
@media all and (max-width: 450px) {
ha-md-textfield#value {
min-width: 60px;
margin: 0 -8px;
}
ha-md-select#type {
min-width: 120px;
width: 120px;
}
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px;

View File

@@ -21,7 +21,7 @@ export interface BackupAddonItem {
@customElement("ha-backup-addons-picker")
export class HaBackupAddonsPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addons!: BackupAddonItem[];
@@ -32,7 +32,7 @@ export class HaBackupAddonsPicker extends LitElement {
private _addons = memoizeOne((addons: BackupAddonItem[]) =>
addons.sort((a, b) =>
stringCompare(a.name, b.name, this.hass?.locale?.language)
stringCompare(a.name, b.name, this.hass.locale.language)
)
);

View File

@@ -47,32 +47,23 @@ interface SelectedItems {
@customElement("ha-backup-data-picker")
export class HaBackupDataPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data!: BackupData;
@property({ attribute: false }) public value?: BackupData;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Array, attribute: "required-items" })
public requiredItems: string[] = [];
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@state() public _addonIcons: Record<string, boolean> = {};
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
if (this.hass && isComponentLoaded(this.hass, "hassio")) {
if (isComponentLoaded(this.hass, "hassio")) {
this._fetchAddonInfo();
}
}
private async _fetchAddonInfo() {
const { addons } = await fetchHassioAddonsInfo(this.hass!);
const { addons } = await fetchHassioAddonsInfo(this.hass);
this._addonIcons = addons.reduce<Record<string, boolean>>(
(acc, addon) => ({
...acc,
@@ -83,14 +74,16 @@ export class HaBackupDataPicker extends LitElement {
}
private _homeAssistantItems = memoizeOne(
(data: BackupData, localize: LocalizeFunc) => {
(data: BackupData, _localize: LocalizeFunc) => {
const items: CheckBoxItem[] = [];
if (data.homeassistant_included) {
items.push({
label: localize(
`ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}`
),
label: data.database_included
? this.hass.localize(
"ui.panel.config.backup.data_picker.settings_and_history"
)
: this.hass.localize("ui.panel.config.backup.data_picker.settings"),
id: "config",
version: data.homeassistant_version,
});
@@ -106,22 +99,18 @@ export class HaBackupDataPicker extends LitElement {
);
private _localizeFolder(folder: string): string {
const localize = this.localize || this.hass!.localize;
switch (folder) {
case "media":
return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.media`
);
return this.hass.localize("ui.panel.config.backup.data_picker.media");
case "share":
return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.share_folder`
return this.hass.localize(
"ui.panel.config.backup.data_picker.share_folder"
);
case "ssl":
return localize(`ui.panel.${this.translationKeyPanel}.data_picker.ssl`);
return this.hass.localize("ui.panel.config.backup.data_picker.ssl");
case "addons/local":
return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.local_addons`
return this.hass.localize(
"ui.panel.config.backup.data_picker.local_addons"
);
}
return capitalizeFirstLetter(folder);
@@ -226,13 +215,14 @@ export class HaBackupDataPicker extends LitElement {
}
protected render() {
const localize = this.localize || this.hass!.localize;
const homeAssistantItems = this._homeAssistantItems(this.data, localize);
const homeAssistantItems = this._homeAssistantItems(
this.data,
this.hass.localize
);
const addonsItems = this._addonsItems(
this.data,
localize,
this.hass.localize,
this._addonIcons
);
@@ -257,7 +247,6 @@ export class HaBackupDataPicker extends LitElement {
selectedItems.homeassistant.length <
homeAssistantItems.length}
@change=${this._sectionChanged}
?disabled=${this.requiredItems.length > 0}
></ha-checkbox>
</ha-formfield>
<div class="items">
@@ -277,7 +266,6 @@ export class HaBackupDataPicker extends LitElement {
item.id
)}
@change=${this._homeassistantChanged}
.disabled=${this.requiredItems.includes(item.id)}
></ha-checkbox>
</ha-formfield>
`
@@ -292,8 +280,8 @@ export class HaBackupDataPicker extends LitElement {
<ha-formfield>
<ha-backup-formfield-label
slot="label"
.label=${localize(
`ui.panel.${this.translationKeyPanel}.data_picker.addons`
.label=${this.hass.localize(
"ui.panel.config.backup.data_picker.addons"
)}
.iconPath=${mdiPuzzle}
>

View File

@@ -1,148 +0,0 @@
import memoizeOne from "memoize-one";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import "./ha-backup-data-picker";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type {
BackupContentExtended,
BackupData,
} from "../../../../data/backup";
import { fireEvent } from "../../../../common/dom/fire_event";
@customElement("ha-backup-details-restore")
class HaBackupDetailsRestore extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Object }) public backup!: BackupContentExtended;
@property({ type: Boolean, attribute: "ha-required" })
public haRequired = false;
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@state() private _selectedData?: BackupData;
protected willUpdate() {
if (!this.hasUpdated && this.haRequired) {
this._selectedData = {
homeassistant_included: true,
folders: [],
addons: [],
homeassistant_version: this.backup.homeassistant_version,
database_included: this.backup.database_included,
};
}
}
render() {
return html`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.title`
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.translationKeyPanel=${this.translationKeyPanel}
.localize=${this.localize}
.hass=${this.hass}
.data=${this.backup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
.requiredItems=${this._isHomeAssistantRequired(this.haRequired)}
>
</ha-backup-data-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._restore}
.disabled=${this._isRestoreDisabled}
destructive
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.action`
)}
</ha-button>
</div>
</ha-card>
`;
}
private _restore() {
fireEvent(this, "backup-restore", { selectedData: this._selectedData });
}
private _selectedBackupChanged(ev: CustomEvent) {
ev.stopPropagation();
this._selectedData = ev.detail.value;
}
private _isHomeAssistantRequired = memoizeOne((required: boolean) =>
required ? ["config"] : []
);
private get _isRestoreDisabled(): boolean {
return (
!this._selectedData ||
(this.haRequired && !this._selectedData.homeassistant_included) ||
!(
this._selectedData?.database_included ||
this._selectedData?.homeassistant_included ||
this._selectedData.addons.length ||
this._selectedData.folders.length
)
);
}
static styles = css`
:host {
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-details-restore": HaBackupDetailsRestore;
}
interface HASSDomEvents {
"backup-restore": { selectedData?: BackupData };
}
}

View File

@@ -1,153 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
formatDateTime,
formatDateTimeWithBrowserDefaults,
} from "../../../../common/datetime/format_date_time";
import {
computeBackupSize,
computeBackupType,
type BackupContentExtended,
} from "../../../../data/backup";
import { fireEvent } from "../../../../common/dom/fire_event";
import { bytesToString } from "../../../../util/bytes-to-string";
declare global {
interface HASSDomEvents {
"show-backup-upload": undefined;
}
}
@customElement("ha-backup-details-summary")
class HaBackupDetailsSummary extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Object }) public backup!: BackupContentExtended;
@property({ type: Boolean, attribute: "hassio" }) public isHassio = false;
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@property({ type: Boolean, attribute: "show-upload-another" })
public showUploadAnother = false;
render() {
const backupDate = new Date(this.backup.date);
const formattedDate = this.hass
? formatDateTime(backupDate, this.hass.locale, this.hass.config)
: formatDateTimeWithBrowserDefaults(backupDate);
return html`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.title`
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
${this.translationKeyPanel === "config.backup"
? html`<ha-md-list-item>
<span slot="headline">
${this.localize("ui.panel.config.backup.backup_type")}
</span>
<span slot="supporting-text">
${this.localize(
`ui.panel.config.backup.type.${computeBackupType(this.backup, this.isHassio)}`
)}
</span>
</ha-md-list-item>`
: nothing}
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.size`
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this.backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.created`
)}
</span>
<span slot="supporting-text"> ${formattedDate} </span>
</ha-md-list-item>
</ha-md-list>
</div>
${this.showUploadAnother
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
private _uploadAnother() {
fireEvent(this, "show-backup-upload");
}
static styles = css`
:host {
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-details-summary": HaBackupDetailsSummary;
}
}

View File

@@ -1,19 +1,16 @@
import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import type { BackupContent, BackupType } from "../../../../../data/backup";
import {
computeBackupSize,
computeBackupType,
getBackupTypes,
type BackupContent,
} from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -24,12 +21,6 @@ interface BackupStats {
size: number;
}
const TYPE_ICONS: Record<BackupType, string> = {
automatic: mdiCalendarSync,
manual: mdiGestureTap,
addon_update: mdiPuzzle,
};
const computeBackupStats = (backups: BackupContent[]): BackupStats =>
backups.reduce(
(stats, backup) => {
@@ -46,22 +37,23 @@ class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public backups: BackupContent[] = [];
private _stats = memoizeOne(
(
backups: BackupContent[],
isHassio: boolean
): [BackupType, BackupStats][] =>
getBackupTypes(isHassio).map((type) => {
const backupsOfType = backups.filter(
(backup) => computeBackupType(backup, isHassio) === type
);
return [type, computeBackupStats(backupsOfType)] as const;
})
);
private _automaticStats = memoizeOne((backups: BackupContent[]) => {
const automaticBackups = backups.filter(
(backup) => backup.with_automatic_settings
);
return computeBackupStats(automaticBackups);
});
private _manualStats = memoizeOne((backups: BackupContent[]) => {
const manualBackups = backups.filter(
(backup) => !backup.with_automatic_settings
);
return computeBackupStats(manualBackups);
});
render() {
const isHassio = isComponentLoaded(this.hass, "hassio");
const stats = this._stats(this.backups, isHassio);
const automaticStats = this._automaticStats(this.backups);
const manualStats = this._manualStats(this.backups);
return html`
<ha-card class="my-backups">
@@ -70,32 +62,44 @@ class HaBackupOverviewBackups extends LitElement {
</div>
<div class="card-content">
<ha-md-list>
${stats.map(
([type, { count, size }]) => html`
<ha-md-list-item
type="link"
href="/config/backup/backups?type=${type}"
>
<ha-svg-icon
slot="start"
.path=${TYPE_ICONS[type]}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.overview.backups.${type}`,
{ count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(size) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
)}
<ha-md-list-item
type="link"
href="/config/backup/backups?type=automatic"
>
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.automatic",
{ count: automaticStats.count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(automaticStats.size, 1) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href="/config/backup/backups?type=manual"
>
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.manual",
{ count: manualStats.count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(manualStats.size, 1) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
<div class="card-actions">

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