Compare commits

...
Author SHA1 Message Date
d82f87f72f Update hassio/src/ingress-view/hassio-ingress-view.ts
Co-authored-by: Bram Kragten <[email protected]>
2021-07-08 16:24:11 +02:00
Joakim Sørensen 4aa759f7f5 Add type 2021-06-30 08:57:09 +00:00
Joakim Sørensen 774393a515 adjust it 2021-06-30 08:48:40 +00:00
Joakim Sørensen 9ced09035f Merge branch 'dev' of https://github.com/home-assistant/frontend into waitfor-ingress 2021-06-30 08:26:13 +00:00
GitHub Action 9594c8106e Translation update 2021-06-30 00:47:15 +00:00
Joakim SørensenandGitHub ed4052365c Allow placeholders in config and option flows (#9314) 2021-06-30 01:09:18 +02:00
Joakim SørensenandGitHub 377ebadc10 Show note about integrations not in UI even for non-advanced (#9457)
* Show note about integrations not in UI even for non-advanced

* Address comments
2021-06-30 01:08:58 +02:00
Charles GarwoodandGitHub ed4809b71e Handle config entry not loaded on Z-Wave JS config panel (#9451)
* Handle config entry not loaded on Z-Wave JS config panel

* Move ERROR_STATES to data/config_entries and import
2021-06-29 19:04:42 -04:00
db37dffdbb Fixed Logbook Card/Panel/Dialog Incorrect Entires for input_datetime Entities (#9399)
Co-authored-by: Bram Kragten <[email protected]>
2021-06-30 00:32:19 +02:00
Bram KragtenandGitHub 13cab7e301 Fix logbook card (#9476) 2021-06-29 18:32:51 +02:00
0a50fc66e5 Add ZWave JS heal network UI (#9449)
* Add Z-Wave JS heal network dialog

* progress bar tweak

* tweak package.json

* typing tweak

* Update yarn.lock

* Align versions

* address review comments

* Use indeterminate linear-progress instead of circular-progress

* cleanup circular-progress

* prettier

* additional review cleanup

* subscribe to status update if heal already running

* more cleanup

* more cleanup

Co-authored-by: Bram Kragten <[email protected]>
2021-06-29 11:14:50 -04:00
a3d1a3566d Fix ha-hls-player cleanup for lit 2 (#9388)
Co-authored-by: Bram Kragten <[email protected]>
2021-06-29 15:44:18 +02:00
Philip AllgaierandGitHub ba0be927ed Set min value for history graph card rendered hours (#9464) 2021-06-29 15:01:42 +02:00
Philip AllgaierandGitHub 4260606267 Set minimum = 1 hours to show (#9466) 2021-06-29 15:01:18 +02:00
Philip AllgaierandGitHub 4665db4f27 Fix integration card rename dialog logic (#9467) 2021-06-29 15:00:27 +02:00
Franck NijhofandGitHub 43503ba085 Fix number entity row availability when state unknown (#9475) 2021-06-29 14:57:39 +02:00
Joakim SørensenandGitHub 0a83a704f1 Ignore previous versions in add-on changelog (#9474) 2021-06-29 14:57:25 +02:00
GitHub Action 08de941c90 Translation update 2021-06-29 00:47:16 +00:00
GitHub Action 62228ef144 Translation update 2021-06-28 00:47:17 +00:00
GitHub Action 9731257782 Translation update 2021-06-27 00:47:51 +00:00
GitHub Action 4ec9c9c16e Translation update 2021-06-26 00:46:57 +00:00
Franck NijhofandGitHub 45436731e2 Fix select entity disabled when no item selected (#9465) 2021-06-24 21:36:23 -07:00
GitHub Action 27730e65e7 Translation update 2021-06-25 00:47:31 +00:00
rianadonandGitHub a4aba93d57 Add input elements to login page for password managers (#9369) 2021-06-24 23:14:36 +02:00
d93db16963 Add button for zwave_js options flow (#9001)
Co-authored-by: Joakim Sørensen <[email protected]>
2021-06-24 13:21:30 +02:00
GitHub Action c327fe11b8 Translation update 2021-06-23 00:48:18 +00:00
GitHub Action 4fbc31d0b0 Translation update 2021-06-22 00:48:29 +00:00
Bram KragtenandGitHub 9a4a1cb4ec Fix charts tooltips and legends (#9448) 2021-06-21 10:38:50 +02:00
Joakim Sørensen 6988cc6170 Rename localize key 2021-06-21 08:10:19 +00:00
202d6957bc Allow clearing values in optional selects (#9442)
Co-authored-by: Bram Kragten <[email protected]>
2021-06-21 09:41:38 +02:00
Joakim SørensenandGitHub 14fcff7774 Fix secrets schema (#9446) 2021-06-21 09:41:06 +02:00
GitHub Action 2c9aa1cab4 Translation update 2021-06-21 00:48:39 +00:00
GitHub Action 7745c10d07 Translation update 2021-06-20 00:48:37 +00:00
c1d571de42 Add Select entity (#9422)
Co-authored-by: Bram Kragten <[email protected]>
2021-06-19 13:26:19 +02:00
GitHub Action cecb66451c Translation update 2021-06-19 00:48:32 +00:00
Joakim Sørensen 56a3c8c0c0 Check status 2021-06-18 14:11:09 +00:00
Joakim Sørensen 1519355d76 prettier 2021-06-18 13:37:51 +00:00
Bram KragtenandGitHub 0ef3421fa2 Bump chartjs to version 3 (#9401) 2021-06-18 11:15:07 +02:00
GitHub Action f88e238d41 Translation update 2021-06-18 00:48:41 +00:00
72d086210d Update hassio/src/ingress-view/hassio-ingress-view.ts
Co-authored-by: Bram Kragten <[email protected]>
2021-06-17 19:39:12 +02:00
Raman GuptaandGitHub ce3c8f264d Update zwave_js log subscription to handle core changes (#9262) 2021-06-17 17:19:18 +02:00
Joakim SørensenandGitHub f812d25024 Update hassio/src/ingress-view/hassio-ingress-view.ts 2021-06-17 15:33:26 +02:00
Joakim SørensenandGitHub 821be14dcb Update hassio/src/ingress-view/hassio-ingress-view.ts 2021-06-17 15:28:17 +02:00
Bram KragtenandGitHub 9fbd594f37 Check if /proc/version exists (#9438) 2021-06-17 15:23:20 +02:00
Bram KragtenandGitHub 5ad95cad90 Support white color mode (#9386) 2021-06-17 15:23:08 +02:00
GitHub Action 7e507b40c4 Translation update 2021-06-17 00:48:10 +00:00
Joakim Sørensen 6eac30a022 use const 2021-06-16 15:25:28 +00:00
Joakim Sørensen eac156d53b Reuse keys 2021-06-16 15:14:31 +00:00
Joakim Sørensen b169ae55e1 Show loading screen while waiting for ingress to be able to show 2021-06-16 15:09:15 +00:00
Joakim SørensenandGitHub 446a9b5c02 Don't clear action on expected error (#9428) 2021-06-16 11:13:30 +02:00
GitHub Action e02e61384e Translation update 2021-06-16 00:48:25 +00:00
Raman GuptaandGitHub 5deb570fdf Add button to download logs from zwave_js logs page (#9395) 2021-06-16 00:02:40 +02:00
Joakim SørensenandGitHub 915c46f144 Fix add-on configuration validation (#9424) 2021-06-15 21:00:28 +02:00
Philip AllgaierandGitHub 30d6c5eaf3 Gracefully handle logbook retrieval errors (#9377) 2021-06-15 19:54:18 +02:00
Philip AllgaierandGitHub 6e50d1166a Only attempt to get "trace/contexts" if admin (#9378)
* Only attempt to get "trace/contexts" if admin

* Changes from review
2021-06-15 16:38:37 +02:00
Philip AllgaierandGitHub 0e3eed0563 Fix supervisor text "error_addon_not_started" (#9412) 2021-06-15 15:38:14 +02:00
Joakim SørensenandGitHub 1b1676cecc Use poll for webpack for WSL (#9425) 2021-06-15 15:34:15 +02:00
GitHub Action d911fe6a0b Translation update 2021-06-15 00:48:36 +00:00
Bram KragtenandGitHub 22253a3385 Add header and description to progress options flow (#9423) 2021-06-15 01:10:24 +02:00
GitHub Action 38640c99e3 Translation update 2021-06-14 00:48:26 +00:00
GitHub Action d6df8bddea Translation update 2021-06-13 00:48:28 +00:00
GitHub Action ddfc4bd98e Translation update 2021-06-12 00:48:12 +00:00
3d6674325c Fix the issue that logbook card doesn't translate context.user_id to name if it's a user's id. (#9383)
Co-authored-by: Bram Kragten <[email protected]>
2021-06-10 14:51:24 +02:00
Bram KragtenandGitHub 194829f5b1 Generalize map (#9331)
* Generalize map

* Fix path opacity

* Add fitZones
2021-06-10 14:22:44 +02:00
GitHub Action 11a77253f4 Translation update 2021-06-10 00:49:02 +00:00
GitHub Action 67be2343f8 Translation update 2021-06-09 00:48:19 +00:00
e9b1b3d853 Fix issues with restoring snapshot during onboarding (#9385)
Co-authored-by: Bram Kragten <[email protected]>
2021-06-08 17:57:53 +02:00
8a33d174d7 FIx selecting service/url path action after choosing default action (#9376)
Co-authored-by: Joakim Sørensen <[email protected]>
2021-06-08 14:24:58 +02:00
Joakim SørensenandGitHub 226d6216b7 Build wheels for 3.9-alpine3.13 (#9390) 2021-06-08 13:07:02 +02:00
GitHub Action 1925bb01be Translation update 2021-06-08 00:49:18 +00:00
Bram Kragten 82a4806e01 Change line logic 2021-06-07 10:45:57 +02:00
ce419fae7b Add password confirmation to snapshot creation (#9349)
* Add password confirmation to snapshot creation

* Remove confirm_password before sending

* change layout

* style changes

* Adjust styling

Co-authored-by: Bram Kragten <[email protected]>
2021-06-07 10:45:20 +02:00
121 changed files with 6956 additions and 3676 deletions
+4 -2
View File
@@ -6,7 +6,6 @@ on:
- published
env:
WHEELS_TAG: 3.8-alpine3.12
PYTHON_VERSION: 3.8
NODE_VERSION: 12.1
@@ -64,6 +63,9 @@ jobs:
strategy:
matrix:
arch: ["aarch64", "armhf", "armv7", "amd64", "i386"]
tag:
- "3.8-alpine3.12"
- "3.9-alpine3.13"
steps:
- name: Download requirements.txt
uses: actions/download-artifact@v2
@@ -73,7 +75,7 @@ jobs:
- name: Build wheels
uses: home-assistant/wheels@master
with:
tag: ${{ env.WHEELS_TAG }}
tag: ${{ matrix.tag }}
arch: ${{ matrix.arch }}
wheels-host: ${{ secrets.WHEELS_HOST }}
wheels-key: ${{ secrets.WHEELS_KEY }}
-2
View File
@@ -5,8 +5,6 @@ const paths = require("./paths.js");
// Files from NPM Packages that should not be imported
module.exports.ignorePackages = ({ latestBuild }) => [
// Bloats bundle and it's not used.
path.resolve(require.resolve("moment"), "../locale"),
// Part of yaml.js and only used for !!js functions that we don't use
require.resolve("esprima"),
];
+10 -2
View File
@@ -1,4 +1,5 @@
// Tasks to run webpack.
const fs = require("fs");
const gulp = require("gulp");
const webpack = require("webpack");
const WebpackDevServer = require("webpack-dev-server");
@@ -18,6 +19,13 @@ const bothBuilds = (createConfigFunc, params) => [
createConfigFunc({ ...params, latestBuild: false }),
];
const isWsl =
fs.existsSync("/proc/version") &&
fs
.readFileSync("/proc/version", "utf-8")
.toLocaleLowerCase()
.includes("microsoft");
/**
* @param {{
* compiler: import("webpack").Compiler,
@@ -79,7 +87,7 @@ const prodBuild = (conf) =>
gulp.task("webpack-watch-app", () => {
// This command will run forever because we don't close compiler
webpack(createAppConfig({ isProdBuild: false, latestBuild: true })).watch(
{ ignored: /build-translations/ },
{ ignored: /build-translations/, poll: isWsl },
doneHandler()
);
gulp.watch(
@@ -137,7 +145,7 @@ gulp.task("webpack-watch-hassio", () => {
isProdBuild: false,
latestBuild: true,
})
).watch({ ignored: /build-translations/ }, doneHandler());
).watch({ ignored: /build-translations/, poll: isWsl }, doneHandler());
gulp.watch(
path.join(paths.translations_src, "en.json"),
@@ -134,7 +134,7 @@ class HassioAddonConfig extends LitElement {
></ha-form>`
: html` <ha-yaml-editor
@value-changed=${this._configChanged}
.schema=${ADDON_YAML_SCHEMA}
.yamlSchema=${ADDON_YAML_SCHEMA}
></ha-yaml-editor>`}
${this._error ? html` <div class="errors">${this._error}</div> ` : ""}
${!this._yamlMode ||
@@ -269,6 +269,9 @@ class HassioAddonConfig extends LitElement {
private async _saveTapped(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
const options: Record<string, unknown> = this._yamlMode
? this._editor?.value
: this._options;
const eventdata = {
success: true,
response: undefined,
@@ -282,13 +285,13 @@ class HassioAddonConfig extends LitElement {
const validation = await validateHassioAddonOption(
this.hass,
this.addon.slug,
this._editor?.value
options
);
if (!validation.valid) {
throw Error(validation.message);
}
await setHassioAddonOption(this.hass, this.addon.slug, {
options: this._yamlMode ? this._editor?.value : this._options,
options,
});
this._configHasChanged = false;
@@ -892,10 +892,19 @@ class HassioAddonInfo extends LitElement {
private async _openChangelog(): Promise<void> {
try {
const content = await fetchHassioAddonChangelog(
this.hass,
this.addon.slug
);
let content = await fetchHassioAddonChangelog(this.hass, this.addon.slug);
if (
content.includes(`# ${this.addon.version}`) &&
content.includes(`# ${this.addon.version_latest}`)
) {
const newcontent = content.split(`# ${this.addon.version}`)[0];
if (newcontent.includes(`# ${this.addon.version_latest}`)) {
// Only change the content if the new version still exist
// if the changelog does not have the newests version on top
// this will not be true, and we don't modify the content
content = newcontent;
}
}
showHassioMarkdownDialog(this, {
title: this.supervisor.localize("addon.dashboard.changelog"),
content,
@@ -29,7 +29,6 @@ class SupervisorFormfieldLabel extends LitElement {
static get styles(): CSSResultGroup {
return css`
:host {
cursor: pointer;
display: flex;
align-items: center;
}
@@ -5,6 +5,7 @@ import { customElement, property } 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 { LocalizeFunc } from "../../../src/common/translations/localize";
import "../../../src/components/ha-checkbox";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-radio";
@@ -67,6 +68,8 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
export class SupervisorSnapshotContent extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public localize?: LocalizeFunc;
@property({ attribute: false }) public supervisor?: Supervisor;
@property({ attribute: false }) public snapshot?: HassioSnapshotDetail;
@@ -81,10 +84,14 @@ export class SupervisorSnapshotContent extends LitElement {
@property({ type: Boolean }) public snapshotHasPassword = false;
@property({ type: Boolean }) public onboarding = false;
@property() public snapshotName = "";
@property() public snapshotPassword = "";
@property() public confirmSnapshotPassword = "";
public willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
@@ -104,8 +111,12 @@ export class SupervisorSnapshotContent extends LitElement {
}
}
private _localize = (string: string) =>
this.supervisor?.localize(`snapshot.${string}`) ||
this.localize!(`ui.panel.page-onboarding.restore.${string}`);
protected render(): TemplateResult {
if (!this.supervisor) {
if (!this.onboarding && !this.supervisor) {
return html``;
}
const foldersSection =
@@ -117,14 +128,16 @@ export class SupervisorSnapshotContent extends LitElement {
${this.snapshot
? html`<div class="details">
${this.snapshot.type === "full"
? this.supervisor.localize("snapshot.full_snapshot")
: this.supervisor.localize("snapshot.partial_snapshot")}
? this._localize("full_snapshot")
: this._localize("partial_snapshot")}
(${Math.ceil(this.snapshot.size * 10) / 10 + " MB"})<br />
${formatDateTime(new Date(this.snapshot.date), this.hass.locale)}
${this.hass
? formatDateTime(new Date(this.snapshot.date), this.hass.locale)
: this.snapshot.date}
</div>`
: html`<paper-input
name="snapshotName"
.label=${this.supervisor.localize("snapshot.name")}
.label=${this.supervisor?.localize("snapshot.name") || "Name"}
.value=${this.snapshotName}
@value-changed=${this._handleTextValueChanged}
>
@@ -132,13 +145,11 @@ export class SupervisorSnapshotContent extends LitElement {
${!this.snapshot || this.snapshot.type === "full"
? html`<div class="sub-header">
${!this.snapshot
? this.supervisor.localize("snapshot.type")
: this.supervisor.localize("snapshot.select_type")}
? this._localize("type")
: this._localize("select_type")}
</div>
<div class="snapshot-types">
<ha-formfield
.label=${this.supervisor.localize("snapshot.full_snapshot")}
>
<ha-formfield .label=${this._localize("full_snapshot")}>
<ha-radio
@change=${this._handleRadioValueChanged}
value="full"
@@ -147,9 +158,7 @@ export class SupervisorSnapshotContent extends LitElement {
>
</ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.supervisor!.localize("snapshot.partial_snapshot")}
>
<ha-formfield .label=${this._localize("partial_snapshot")}>
<ha-radio
@change=${this._handleRadioValueChanged}
value="partial"
@@ -160,9 +169,9 @@ export class SupervisorSnapshotContent extends LitElement {
</ha-formfield>
</div>`
: ""}
${this.snapshot && this.snapshotType === "partial"
? html`
${this.snapshot.homeassistant
${this.snapshotType === "partial"
? html`<div class="partial-picker">
${this.snapshot && this.snapshot.homeassistant
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
@@ -182,15 +191,11 @@ export class SupervisorSnapshotContent extends LitElement {
</ha-formfield>
`
: ""}
`
: ""}
${this.snapshotType === "partial"
? html`
${foldersSection?.templates.length
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this.supervisor.localize("snapshot.folders")}
.label=${this._localize("folders")}
.iconPath=${mdiFolder}
>
</supervisor-formfield-label>`}
@@ -210,7 +215,7 @@ export class SupervisorSnapshotContent extends LitElement {
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this.supervisor.localize("snapshot.addons")}
.label=${this._localize("addons")}
.iconPath=${mdiPuzzle}
>
</supervisor-formfield-label>`}
@@ -226,29 +231,44 @@ export class SupervisorSnapshotContent extends LitElement {
<div class="section-content">${addonsSection.templates}</div>
`
: ""}
`
</div> `
: ""}
${this.snapshotType === "partial" &&
(!this.snapshot || this.snapshotHasPassword)
? html`<hr />`
: ""}
${!this.snapshot
? html`<ha-formfield
.label=${this.supervisor.localize("snapshot.password_protection")}
class="password"
.label=${this._localize("password_protection")}
>
<ha-checkbox
.checked=${this.snapshotHasPassword}
@change=${this._toggleHasPassword}
>
</ha-checkbox
></ha-formfield>`
</ha-checkbox>
</ha-formfield>`
: ""}
${this.snapshotHasPassword
? html`
<paper-input
.label=${this.supervisor.localize("snapshot.password")}
.label=${this._localize("password")}
type="password"
name="snapshotPassword"
.value=${this.snapshotPassword}
@value-changed=${this._handleTextValueChanged}
>
</paper-input>
${!this.snapshot
? html` <paper-input
.label=${this.supervisor?.localize("confirm_password")}
type="password"
name="confirmSnapshotPassword"
.value=${this.confirmSnapshotPassword}
@value-changed=${this._handleTextValueChanged}
>
</paper-input>`
: ""}
`
: ""}
`;
@@ -256,21 +276,24 @@ export class SupervisorSnapshotContent extends LitElement {
static get styles(): CSSResultGroup {
return css`
ha-checkbox {
--mdc-checkbox-touch-target-size: 16px;
.partial-picker ha-formfield {
display: block;
margin: 4px 12px 8px 0;
}
ha-formfield {
display: contents;
.partial-picker ha-checkbox {
--mdc-checkbox-touch-target-size: 32px;
}
.partial-picker {
display: block;
margin: 0px -6px;
}
supervisor-formfield-label {
display: inline-flex;
align-items: center;
}
paper-input[type="password"] {
display: block;
margin: 4px 0 4px 16px;
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: 16px 0;
}
.details {
color: var(--secondary-text-color);
@@ -278,13 +301,15 @@ export class SupervisorSnapshotContent extends LitElement {
.section-content {
display: flex;
flex-direction: column;
margin-left: 16px;
margin-left: 30px;
}
.security {
margin-top: 16px;
ha-formfield.password {
display: block;
margin: 0 -14px -16px;
}
.snapshot-types {
display: flex;
margin-left: -13px;
}
.sub-header {
margin-top: 8px;
@@ -303,6 +328,9 @@ export class SupervisorSnapshotContent extends LitElement {
if (this.snapshotHasPassword) {
data.password = this.snapshotPassword;
if (!this.snapshot) {
data.confirm_password = this.confirmSnapshotPassword;
}
}
if (this.snapshotType === "full") {
@@ -334,7 +362,7 @@ export class SupervisorSnapshotContent extends LitElement {
const addons =
section === "addons"
? new Map(
this.supervisor!.addon.addons.map((item) => [item.slug, item])
this.supervisor?.addon.addons.map((item) => [item.slug, item])
)
: undefined;
let checkedItems = 0;
@@ -344,6 +372,7 @@ export class SupervisorSnapshotContent extends LitElement {
.label=${item.name}
.iconPath=${section === "addons" ? mdiPuzzle : mdiFolder}
.imageUrl=${section === "addons" &&
!this.onboarding &&
atLeastVersion(this.hass.config.version, 0, 105) &&
addons?.get(item.slug)?.icon
? `/api/hassio/addons/${item.slug}/icon`
@@ -95,16 +95,25 @@ class HassioCreateSnapshotDialog extends LitElement {
this._creatingSnapshot = true;
this._error = "";
if (
this._snapshotContent.snapshotHasPassword &&
!this._snapshotContent.snapshotPassword.length
) {
if (snapshotDetails.password && !snapshotDetails.password.length) {
this._error = this._dialogParams!.supervisor.localize(
"snapshot.enter_password"
);
this._creatingSnapshot = false;
return;
}
if (
snapshotDetails.password &&
snapshotDetails.password !== snapshotDetails.confirm_password
) {
this._error = this._dialogParams!.supervisor.localize(
"snapshot.passwords_not_matching"
);
this._creatingSnapshot = false;
return;
}
delete snapshotDetails.confirm_password;
try {
if (this._snapshotContent.snapshotType === "full") {
@@ -1,13 +1,13 @@
import { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical } from "@mdi/js";
import { mdiClose, mdiDotsVertical } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../src/common/dom/fire_event";
import { slugify } from "../../../../src/common/string/slugify";
import "../../../../src/components/buttons/ha-progress-button";
import "../../../../src/components/ha-button-menu";
import { createCloseHeading } from "../../../../src/components/ha-dialog";
import "../../../../src/components/ha-header-bar";
import "../../../../src/components/ha-svg-icon";
import { getSignedPath } from "../../../../src/data/auth";
import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
@@ -67,14 +67,24 @@ class HassioSnapshotDialog
open
scrimClickAction
@closed=${this.closeDialog}
.heading=${createCloseHeading(this.hass, this._computeName)}
.heading=${true}
>
<div slot="heading">
<ha-header-bar>
<span slot="title">${this._snapshot.name}</span>
<mwc-icon-button slot="actionItems" dialogAction="cancel">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>
</ha-header-bar>
</div>
${this._restoringSnapshot
? html` <ha-circular-progress active></ha-circular-progress>`
: html`<supervisor-snapshot-content
.hass=${this.hass}
.supervisor=${this._dialogParams.supervisor}
.snapshot=${this._snapshot}
.onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
>
</supervisor-snapshot-content>`}
${this._error ? html`<p class="error">Error: ${this._error}</p>` : ""}
@@ -87,18 +97,20 @@ class HassioSnapshotDialog
Restore
</mwc-button>
<ha-button-menu
fixed
slot="primaryAction"
@action=${this._handleMenuAction}
@closed=${(ev: Event) => ev.stopPropagation()}
>
<mwc-icon-button slot="trigger" alt="menu">
<ha-svg-icon .path=${mdiDotsVertical}></ha-svg-icon>
</mwc-icon-button>
<mwc-list-item>Download Snapshot</mwc-list-item>
<mwc-list-item class="error">Delete Snapshot</mwc-list-item>
</ha-button-menu>
${!this._dialogParams.onboarding
? html`<ha-button-menu
fixed
slot="primaryAction"
@action=${this._handleMenuAction}
@closed=${(ev: Event) => ev.stopPropagation()}
>
<mwc-icon-button slot="trigger" alt="menu">
<ha-svg-icon .path=${mdiDotsVertical}></ha-svg-icon>
</mwc-icon-button>
<mwc-list-item>Download Snapshot</mwc-list-item>
<mwc-list-item class="error">Delete Snapshot</mwc-list-item>
</ha-button-menu>`
: ""}
</ha-dialog>
`;
}
@@ -115,6 +127,12 @@ class HassioSnapshotDialog
display: block;
text-align: center;
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
display: block;
}
`,
];
}
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../src/common/dom/fire_event";
import { LocalizeFunc } from "../../../../src/common/translations/localize";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioSnapshotDialogParams {
@@ -6,6 +7,7 @@ export interface HassioSnapshotDialogParams {
onDelete?: () => void;
onboarding?: boolean;
supervisor?: Supervisor;
localize?: LocalizeFunc;
}
export const showHassioSnapshotDialog = (
@@ -158,8 +158,8 @@ class DialogSupervisorUpdate extends LitElement {
} catch (err) {
if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
this._error = extractApiErrorMessage(err);
this._action = null;
}
this._action = null;
return;
}
+99 -21
View File
@@ -27,20 +27,26 @@ import "../../../src/layouts/hass-loading-screen";
import "../../../src/layouts/hass-subpage";
import { HomeAssistant, Route } from "../../../src/types";
const STATUS_BAD_GATEWAY = 502;
const TIMEOUT = 60000;
@customElement("hassio-ingress-view")
class HassioIngressView extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public supervisor!: Supervisor;
@property() public route!: Route;
@property({ attribute: false }) public route!: Route;
@property() public ingressPanel = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public ingressPanel = false;
@state() private _addon?: HassioAddonDetails;
@property({ type: Boolean })
public narrow = false;
@state() private _resolveIngressStatus?: number;
private _resolveIngressTime?: number;
private _sessionKeepAlive?: number;
@@ -51,11 +57,76 @@ class HassioIngressView extends LitElement {
clearInterval(this._sessionKeepAlive);
this._sessionKeepAlive = undefined;
}
this._resolveIngressStatus = undefined;
this._resolveIngressTime = undefined;
}
private async _resolveURL(addonSlug: string): Promise<void> {
await this._fetchData(addonSlug);
if (!this._addon) {
window.setTimeout(async () => {
this._resolveURL(addonSlug);
}, 1000);
return;
}
if (!this._resolveIngressTime) {
this._resolveIngressTime = new Date().getTime();
}
if (
this._resolveIngressStatus &&
this._resolveIngressStatus !== STATUS_BAD_GATEWAY
) {
return;
}
if (
this._resolveIngressTime &&
new Date().getTime() > this._resolveIngressTime + TIMEOUT
) {
await showAlertDialog(this, {
text:
this.hass.localize("ingress.timeout") ||
"Timeout while waiting for add-on to start, check the add-on logs",
title: this._addon.name,
confirmText:
this.hass.localize("ingress.go_to_logs") || "Go to add-on logs",
});
await nextRender();
navigate(`/hassio/addon/${this._addon.slug}/logs`, { replace: true });
return;
}
try {
const response = await fetch(this._addon.ingress_url!);
this._resolveIngressStatus = response.status;
} catch (err) {
// eslint-disable-next-line
console.error(err);
}
window.setTimeout(async () => {
await this._resolveURL(this._addon!.slug);
}, 1000);
}
protected render(): TemplateResult {
if (!this._addon) {
return html` <hass-loading-screen></hass-loading-screen> `;
if (!this._addon || this._resolveIngressStatus === STATUS_BAD_GATEWAY) {
return html`
<hass-loading-screen
.narrow=${this.narrow}
.header=${this._addon?.name}
>
${this._resolveIngressStatus === STATUS_BAD_GATEWAY
? html`<p>
${this.hass.localize("ingress.waiting") ||
"Waiting for add-on to start"}
</p>`
: ""}
</hass-loading-screen>
`;
}
const iframe = html`<iframe src=${this._addon.ingress_url!}></iframe>`;
@@ -134,20 +205,22 @@ class HassioIngressView extends LitElement {
const oldAddon = oldRoute ? oldRoute.path.substr(1) : undefined;
if (addon && addon !== oldAddon) {
this._fetchData(addon);
this._resolveURL(addon);
}
}
private async _fetchData(addonSlug: string) {
const createSessionPromise = createHassioSession(this.hass);
let addon;
let addon: HassioAddonDetails;
try {
addon = await fetchHassioAddonInfo(this.hass, addonSlug);
} catch (err) {
await showAlertDialog(this, {
text: "Unable to fetch add-on info to start Ingress",
text:
this.hass.localize("ingress.unable_to_fetch") ||
"Unable to fetch add-on info to start Ingress",
title: "Supervisor",
});
await nextRender();
@@ -157,7 +230,9 @@ class HassioIngressView extends LitElement {
if (!addon.ingress_url) {
await showAlertDialog(this, {
text: "Add-on does not support Ingress",
text:
this.hass.localize("ingress.no_ingress") ||
"Add-on does not support Ingress",
title: addon.name,
});
await nextRender();
@@ -167,7 +242,9 @@ class HassioIngressView extends LitElement {
if (addon.state !== "started") {
await showAlertDialog(this, {
text: "Add-on is not running. Please start it first",
text:
this.hass.localize("ingress.not_running") ||
"The add-on is not running, please start it.",
title: addon.name,
});
await nextRender();
@@ -181,7 +258,9 @@ class HassioIngressView extends LitElement {
session = await createSessionPromise;
} catch (err) {
await showAlertDialog(this, {
text: "Unable to create an Ingress session",
text:
this.hass.localize("ingress.unable_to_create") ||
"Unable to create an Ingress session",
title: addon.name,
});
await nextRender();
@@ -189,16 +268,15 @@ class HassioIngressView extends LitElement {
return;
}
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
if (!this._sessionKeepAlive) {
this._sessionKeepAlive = window.setInterval(async () => {
try {
await validateHassioSession(this.hass, session);
} catch (err) {
session = await createHassioSession(this.hass);
}
}, 60000);
}
this._sessionKeepAlive = window.setInterval(async () => {
try {
await validateHassioSession(this.hass, session);
} catch (err) {
session = await createHassioSession(this.hass);
}
}, 60000);
this._addon = addon;
}
+17 -18
View File
@@ -44,20 +44,21 @@
"@fullcalendar/list": "5.1.0",
"@lit-labs/virtualizer": "^0.6.0",
"@material/chips": "=12.0.0-canary.1a8d06483.0",
"@material/mwc-button": "canary",
"@material/mwc-checkbox": "canary",
"@material/mwc-circular-progress": "canary",
"@material/mwc-dialog": "canary",
"@material/mwc-fab": "canary",
"@material/mwc-formfield": "canary",
"@material/mwc-icon-button": "canary",
"@material/mwc-list": "canary",
"@material/mwc-menu": "canary",
"@material/mwc-radio": "canary",
"@material/mwc-ripple": "canary",
"@material/mwc-switch": "canary",
"@material/mwc-tab": "canary",
"@material/mwc-tab-bar": "canary",
"@material/mwc-button": "0.22.0-canary.cc04657a.0",
"@material/mwc-checkbox": "0.22.0-canary.cc04657a.0",
"@material/mwc-circular-progress": "0.22.0-canary.cc04657a.0",
"@material/mwc-dialog": "0.22.0-canary.cc04657a.0",
"@material/mwc-fab": "0.22.0-canary.cc04657a.0",
"@material/mwc-formfield": "0.22.0-canary.cc04657a.0",
"@material/mwc-icon-button": "0.22.0-canary.cc04657a.0",
"@material/mwc-linear-progress": "0.22.0-canary.cc04657a.0",
"@material/mwc-list": "0.22.0-canary.cc04657a.0",
"@material/mwc-menu": "0.22.0-canary.cc04657a.0",
"@material/mwc-radio": "0.22.0-canary.cc04657a.0",
"@material/mwc-ripple": "0.22.0-canary.cc04657a.0",
"@material/mwc-switch": "0.22.0-canary.cc04657a.0",
"@material/mwc-tab": "0.22.0-canary.cc04657a.0",
"@material/mwc-tab-bar": "0.22.0-canary.cc04657a.0",
"@material/top-app-bar": "=12.0.0-canary.1a8d06483.0",
"@mdi/js": "5.9.55",
"@mdi/svg": "5.9.55",
@@ -66,9 +67,7 @@
"@polymer/iron-autogrow-textarea": "^3.0.1",
"@polymer/iron-flex-layout": "^3.0.1",
"@polymer/iron-icon": "^3.0.1",
"@polymer/iron-image": "^3.0.1",
"@polymer/iron-input": "^3.0.1",
"@polymer/iron-label": "^3.0.1",
"@polymer/iron-overlay-behavior": "^3.0.2",
"@polymer/iron-resizable-behavior": "^3.0.1",
"@polymer/paper-checkbox": "^3.1.0",
@@ -98,11 +97,11 @@
"@vibrant/quantizer-mmcq": "^3.2.1-alpha.1",
"@vue/web-component-wrapper": "^1.2.0",
"@webcomponents/webcomponentsjs": "^2.2.7",
"chart.js": "^2.9.4",
"chartjs-chart-timeline": "^0.4.0",
"chart.js": "^3.3.2",
"comlink": "^4.3.1",
"core-js": "^3.6.5",
"cropperjs": "^1.5.11",
"date-fns": "^2.22.1",
"deep-clone-simple": "^1.1.1",
"deep-freeze": "^0.0.1",
"fecha": "^4.2.0",
+24 -3
View File
@@ -7,6 +7,7 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import "./ha-password-manager-polyfill";
import { property, state } from "lit/decorators";
import "../components/ha-form/ha-form";
import "../components/ha-markdown";
@@ -20,7 +21,7 @@ import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
type State = "loading" | "error" | "step";
class HaAuthFlow extends litLocalizeLiteMixin(LitElement) {
@property() public authProvider?: AuthProvider;
@property({ attribute: false }) public authProvider?: AuthProvider;
@property() public clientId?: string;
@@ -37,7 +38,15 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) {
@state() private _errorMessage?: string;
protected render() {
return html` <form>${this._renderForm()}</form> `;
return html`
<form>${this._renderForm()}</form>
<ha-password-manager-polyfill
.step=${this._step}
.stepData=${this._stepData}
@form-submitted=${this._handleSubmit}
@value-changed=${this._stepDataChanged}
></ha-password-manager-polyfill>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
@@ -231,11 +240,17 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) {
await this.updateComplete;
// 100ms to give all the form elements time to initialize.
setTimeout(() => {
const form = this.shadowRoot!.querySelector("ha-form");
const form = this.renderRoot.querySelector("ha-form");
if (form) {
(form as any).focus();
}
}, 100);
setTimeout(() => {
this.renderRoot.querySelector(
"ha-password-manager-polyfill"
)!.boundingRect = this.getBoundingClientRect();
}, 500);
}
private _stepDataChanged(ev: CustomEvent) {
@@ -329,3 +344,9 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) {
}
}
customElements.define("ha-auth-flow", HaAuthFlow);
declare global {
interface HTMLElementTagNameMap {
"ha-auth-flow": HaAuthFlow;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { HaFormSchema } from "../components/ha-form/ha-form";
import { DataEntryFlowStep } from "../data/data_entry_flow";
declare global {
interface HTMLElementTagNameMap {
"ha-password-manager-polyfill": HaPasswordManagerPolyfill;
}
interface HASSDomEvents {
"form-submitted": undefined;
}
}
const ENABLED_HANDLERS = [
"homeassistant",
"legacy_api_password",
"command_line",
];
@customElement("ha-password-manager-polyfill")
export class HaPasswordManagerPolyfill extends LitElement {
@property({ attribute: false }) public step?: DataEntryFlowStep;
@property({ attribute: false }) public stepData: any;
@property({ attribute: false }) public boundingRect?: DOMRect;
protected createRenderRoot() {
// Add under document body so the element isn't placed inside any shadow roots
return document.body;
}
private get styles() {
return `
.password-manager-polyfill {
position: absolute;
top: ${this.boundingRect?.y || 148}px;
left: calc(50% - ${(this.boundingRect?.width || 360) / 2}px);
width: ${this.boundingRect?.width || 360}px;
opacity: 0;
z-index: -1;
}
.password-manager-polyfill input {
width: 100%;
height: 62px;
padding: 0;
border: 0;
}
.password-manager-polyfill input[type="submit"] {
width: 0;
height: 0;
}
`;
}
protected render(): TemplateResult {
if (
this.step &&
this.step.type === "form" &&
this.step.step_id === "init" &&
ENABLED_HANDLERS.includes(this.step.handler[0])
) {
return html`
<form
class="password-manager-polyfill"
aria-hidden="true"
@submit=${this._handleSubmit}
>
${this.step.data_schema.map((input) => this.render_input(input))}
<input type="submit" />
<style>
${this.styles}
</style>
</form>
`;
}
return html``;
}
private render_input(schema: HaFormSchema): TemplateResult | string {
const inputType = schema.name.includes("password") ? "password" : "text";
if (schema.type !== "string") {
return "";
}
return html`
<input
tabindex="-1"
.id=${schema.name}
.type=${inputType}
.value=${this.stepData[schema.name] || ""}
@input=${this._valueChanged}
/>
`;
}
private _handleSubmit(ev: Event) {
ev.preventDefault();
fireEvent(this, "form-submitted");
}
private _valueChanged(ev: Event) {
const target = ev.target! as HTMLInputElement;
this.stepData = { ...this.stepData, [target.id]: target.value };
fireEvent(this, "value-changed", {
value: this.stepData,
});
}
}
+63
View File
@@ -0,0 +1,63 @@
export const COLORS = [
"#377eb8",
"#984ea3",
"#00d2d5",
"#ff7f00",
"#af8d00",
"#7f80cd",
"#b3e900",
"#c42e60",
"#a65628",
"#f781bf",
"#8dd3c7",
"#bebada",
"#fb8072",
"#80b1d3",
"#fdb462",
"#fccde5",
"#bc80bd",
"#ffed6f",
"#c4eaff",
"#cf8c00",
"#1b9e77",
"#d95f02",
"#e7298a",
"#e6ab02",
"#a6761d",
"#0097ff",
"#00d067",
"#f43600",
"#4ba93b",
"#5779bb",
"#927acc",
"#97ee3f",
"#bf3947",
"#9f5b00",
"#f48758",
"#8caed6",
"#f2b94f",
"#eff26e",
"#e43872",
"#d9b100",
"#9d7a00",
"#698cff",
"#d9d9d9",
"#00d27e",
"#d06800",
"#009f82",
"#c49200",
"#cbe8ff",
"#fecddf",
"#c27eb6",
"#8cd2ce",
"#c4b8d9",
"#f883b0",
"#a49100",
"#f48800",
"#27d0df",
"#a04a9b",
];
export function getColorByIndex(index: number) {
return COLORS[index % COLORS.length];
}
+1 -1
View File
@@ -1,4 +1,4 @@
const luminosity = (rgb: [number, number, number]): number => {
export const luminosity = (rgb: [number, number, number]): number => {
// http://www.w3.org/TR/WCAG20/#relativeluminancedef
const lum: [number, number, number] = [0, 0, 0];
for (let i = 0; i < rgb.length; i++) {
+3
View File
@@ -42,6 +42,7 @@ export const FIXED_DOMAIN_ICONS = {
remote: "hass:remote",
scene: "hass:palette",
script: "hass:script-text",
select: "hass:format-list-bulleted",
sensor: "hass:eye",
simple_alarm: "hass:bell",
sun: "hass:white-balance-sunny",
@@ -83,6 +84,7 @@ export const DOMAINS_WITH_CARD = [
"number",
"scene",
"script",
"select",
"timer",
"vacuum",
"water_heater",
@@ -121,6 +123,7 @@ export const DOMAINS_HIDE_MORE_INFO = [
"input_text",
"number",
"scene",
"select",
];
/** Domains that should have the history hidden in the more info dialog. */
+13
View File
@@ -17,6 +17,19 @@ export const formatDate = toLocaleDateStringSupportsOptions
formatDateMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "longDate");
const formatDateShortMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
day: "numeric",
month: "short",
})
);
export const formatDateShort = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateShortMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "shortDate");
const formatDateWeekdayMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
+1 -6
View File
@@ -6,8 +6,7 @@ export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
darkMode?: boolean,
draw = false
darkMode?: boolean
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
@@ -17,10 +16,6 @@ export const setupLeafletMap = async (
.default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
if (draw) {
await import("leaflet-draw");
}
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
+48 -24
View File
@@ -29,37 +29,61 @@ export const computeStateDisplay = (
const domain = computeStateDomain(stateObj);
if (domain === "input_datetime") {
let date: Date;
if (!stateObj.attributes.has_time) {
if (state) {
// If trying to display an explicit state, need to parse the explict state to `Date` then format.
// Attributes aren't available, we have to use `state`.
try {
const components = state.split(" ");
if (components.length === 2) {
// Date and time.
return formatDateTime(new Date(components.join("T")), locale);
}
if (components.length === 1) {
if (state.includes("-")) {
// Date only.
return formatDate(new Date(`${state}T00:00`), locale);
}
if (state.includes(":")) {
// Time only.
const now = new Date();
return formatTime(
new Date(`${now.toISOString().split("T")[0]}T${state}`),
locale
);
}
}
return state;
} catch {
// Formatting methods may throw error if date parsing doesn't go well,
// just return the state string in that case.
return state;
}
} else {
// If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format.
let date: Date;
if (!stateObj.attributes.has_time) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day
);
return formatDate(date, locale);
}
if (!stateObj.attributes.has_date) {
date = new Date();
date.setHours(stateObj.attributes.hour, stateObj.attributes.minute);
return formatTime(date, locale);
}
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day
);
return formatDate(date, locale);
}
if (!stateObj.attributes.has_date) {
const now = new Date();
date = new Date(
// Due to bugs.chromium.org/p/chromium/issues/detail?id=797548
// don't use artificial 1970 year.
now.getFullYear(),
now.getMonth(),
now.getDay(),
stateObj.attributes.day,
stateObj.attributes.hour,
stateObj.attributes.minute
);
return formatTime(date, locale);
return formatDateTime(date, locale);
}
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day,
stateObj.attributes.hour,
stateObj.attributes.minute
);
return formatDateTime(date, locale);
}
if (domain === "humidifier") {
+2
View File
@@ -0,0 +1,2 @@
export const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
+5 -8
View File
@@ -29,31 +29,28 @@ export const iconColorCSS = css`
}
ha-icon[data-domain="climate"][data-state="cooling"] {
color: var(--cool-color, #2b9af9);
color: var(--cool-color, var(--state-climate-cool-color));
}
ha-icon[data-domain="climate"][data-state="heating"] {
color: var(--heat-color, #ff8100);
color: var(--heat-color, var(--state-climate-heat-color));
}
ha-icon[data-domain="climate"][data-state="drying"] {
color: var(--dry-color, #efbd07);
color: var(--dry-color, var(--state-climate-dry-color));
}
ha-icon[data-domain="alarm_control_panel"] {
color: var(--alarm-color-armed, var(--label-badge-red));
}
ha-icon[data-domain="alarm_control_panel"][data-state="disarmed"] {
color: var(--alarm-color-disarmed, var(--label-badge-green));
}
ha-icon[data-domain="alarm_control_panel"][data-state="pending"],
ha-icon[data-domain="alarm_control_panel"][data-state="arming"] {
color: var(--alarm-color-pending, var(--label-badge-yellow));
animation: pulse 1s infinite;
}
ha-icon[data-domain="alarm_control_panel"][data-state="triggered"] {
color: var(--alarm-color-triggered, var(--label-badge-red));
animation: pulse 1s infinite;
@@ -73,11 +70,11 @@ export const iconColorCSS = css`
ha-icon[data-domain="plant"][data-state="problem"],
ha-icon[data-domain="zwave"][data-state="dead"] {
color: var(--error-state-color, #db4437);
color: var(--state-icon-error-color);
}
/* Color the icon if unavailable */
ha-icon[data-state="unavailable"] {
color: var(--state-icon-unavailable-color);
color: var(--state-unavailable-color);
}
`;
+10 -22
View File
@@ -5,32 +5,20 @@
// as much as it can, without ever going more than once per `wait` duration;
// but if you'd like to disable the execution on the leading edge, pass
// `false for leading`. To disable execution on the trailing edge, ditto.
export const throttle = <T extends (...args) => unknown>(
func: T,
export const throttle = <T extends any[]>(
func: (...args: T) => void,
wait: number,
leading = true,
trailing = true
): T => {
) => {
let timeout: number | undefined;
let previous = 0;
let context: any;
let args: any;
const later = () => {
previous = leading === false ? 0 : Date.now();
timeout = undefined;
func.apply(context, args);
if (!timeout) {
context = null;
args = null;
}
};
// @ts-ignore
return function (...argmnts) {
// @ts-ignore
// @typescript-eslint/no-this-alias
context = this;
args = argmnts;
return (...args: T): void => {
const later = () => {
previous = leading === false ? 0 : Date.now();
timeout = undefined;
func(...args);
};
const now = Date.now();
if (!previous && leading === false) {
previous = now;
@@ -42,7 +30,7 @@ export const throttle = <T extends (...args) => unknown>(
timeout = undefined;
}
previous = now;
func.apply(context, args);
func(...args);
} else if (!timeout && trailing !== false) {
timeout = window.setTimeout(later, remaining);
}
+197
View File
@@ -0,0 +1,197 @@
import { _adapters } from "chart.js";
import {
startOfSecond,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
startOfMonth,
startOfQuarter,
startOfYear,
addMilliseconds,
addSeconds,
addMinutes,
addHours,
addDays,
addWeeks,
addMonths,
addQuarters,
addYears,
differenceInMilliseconds,
differenceInSeconds,
differenceInMinutes,
differenceInHours,
differenceInDays,
differenceInWeeks,
differenceInMonths,
differenceInQuarters,
differenceInYears,
endOfSecond,
endOfMinute,
endOfHour,
endOfDay,
endOfWeek,
endOfMonth,
endOfQuarter,
endOfYear,
} from "date-fns";
import { formatDate, formatDateShort } from "../../common/datetime/format_date";
import {
formatDateTime,
formatDateTimeWithSeconds,
} from "../../common/datetime/format_date_time";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
const FORMATS = {
datetime: "datetime",
datetimeseconds: "datetimeseconds",
millisecond: "millisecond",
second: "second",
minute: "minute",
hour: "hour",
day: "day",
week: "week",
month: "month",
quarter: "quarter",
year: "year",
};
_adapters._date.override({
formats: () => FORMATS,
parse: (value: Date | number) => {
if (!(value instanceof Date)) {
return value;
}
return value.getTime();
},
format: function (time, fmt: keyof typeof FORMATS) {
switch (fmt) {
case "datetime":
return formatDateTime(new Date(time), this.options.locale);
case "datetimeseconds":
return formatDateTimeWithSeconds(new Date(time), this.options.locale);
case "millisecond":
return formatTimeWithSeconds(new Date(time), this.options.locale);
case "second":
return formatTimeWithSeconds(new Date(time), this.options.locale);
case "minute":
return formatTime(new Date(time), this.options.locale);
case "hour":
return formatTime(new Date(time), this.options.locale);
case "day":
return formatDateShort(new Date(time), this.options.locale);
case "week":
return formatDate(new Date(time), this.options.locale);
case "month":
return formatDate(new Date(time), this.options.locale);
case "quarter":
return formatDate(new Date(time), this.options.locale);
case "year":
return formatDate(new Date(time), this.options.locale);
default:
return "";
}
},
// @ts-ignore
add: (time, amount, unit) => {
switch (unit) {
case "millisecond":
return addMilliseconds(time, amount);
case "second":
return addSeconds(time, amount);
case "minute":
return addMinutes(time, amount);
case "hour":
return addHours(time, amount);
case "day":
return addDays(time, amount);
case "week":
return addWeeks(time, amount);
case "month":
return addMonths(time, amount);
case "quarter":
return addQuarters(time, amount);
case "year":
return addYears(time, amount);
default:
return time;
}
},
diff: (max, min, unit) => {
switch (unit) {
case "millisecond":
return differenceInMilliseconds(max, min);
case "second":
return differenceInSeconds(max, min);
case "minute":
return differenceInMinutes(max, min);
case "hour":
return differenceInHours(max, min);
case "day":
return differenceInDays(max, min);
case "week":
return differenceInWeeks(max, min);
case "month":
return differenceInMonths(max, min);
case "quarter":
return differenceInQuarters(max, min);
case "year":
return differenceInYears(max, min);
default:
return 0;
}
},
// @ts-ignore
startOf: (time, unit, weekday) => {
switch (unit) {
case "second":
return startOfSecond(time);
case "minute":
return startOfMinute(time);
case "hour":
return startOfHour(time);
case "day":
return startOfDay(time);
case "week":
return startOfWeek(time);
case "isoWeek":
return startOfWeek(time, {
weekStartsOn: +weekday! as 0 | 1 | 2 | 3 | 4 | 5 | 6,
});
case "month":
return startOfMonth(time);
case "quarter":
return startOfQuarter(time);
case "year":
return startOfYear(time);
default:
return time;
}
},
// @ts-ignore
endOf: (time, unit) => {
switch (unit) {
case "second":
return endOfSecond(time);
case "minute":
return endOfMinute(time);
case "hour":
return endOfHour(time);
case "day":
return endOfDay(time);
case "week":
return endOfWeek(time);
case "month":
return endOfMonth(time);
case "quarter":
return endOfQuarter(time);
case "year":
return endOfYear(time);
default:
return time;
}
},
});
+315
View File
@@ -0,0 +1,315 @@
import type {
Chart,
ChartType,
ChartData,
ChartOptions,
TooltipModel,
} from "chart.js";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { clamp } from "../../common/number/clamp";
interface Tooltip extends TooltipModel<any> {
top: string;
left: string;
}
@customElement("ha-chart-base")
export default class HaChartBase extends LitElement {
public chart?: Chart;
@property()
public chartType: ChartType = "line";
@property({ attribute: false })
public data: ChartData = { datasets: [] };
@property({ attribute: false })
public options?: ChartOptions;
@state() private _tooltip?: Tooltip;
@state() private _height?: string;
@state() private _hiddenDatasets: Set<number> = new Set();
protected firstUpdated() {
this._setupChart();
this.data.datasets.forEach((dataset, index) => {
if (dataset.hidden) {
this._hiddenDatasets.add(index);
}
});
}
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this.hasUpdated || !this.chart) {
return;
}
if (changedProps.has("type")) {
this.chart.config.type = this.chartType;
}
if (changedProps.has("data")) {
this.chart.data = this.data;
}
if (changedProps.has("options")) {
this.chart.options = this._createOptions();
}
this.chart.update("none");
}
protected render() {
return html`
${this.options?.plugins?.legend?.display === true
? html` <div class="chartLegend">
<ul>
${this.data.datasets.map(
(dataset, index) => html`<li
.datasetIndex=${index}
@click=${this._legendClick}
class=${classMap({
hidden: this._hiddenDatasets.has(index),
})}
>
<div
class="bullet"
style=${styleMap({
backgroundColor: dataset.backgroundColor as string,
borderColor: dataset.borderColor as string,
})}
></div>
${dataset.label}
</li>`
)}
</ul>
</div>`
: ""}
<div
class="chartContainer"
style=${styleMap({
height:
this.chartType === "timeline"
? `${this.data.datasets.length * 30 + 30}px`
: this._height,
overflow: this._height ? "initial" : "hidden",
})}
>
<canvas></canvas>
${this._tooltip
? html`<div
class="chartTooltip ${classMap({ [this._tooltip.yAlign]: true })}"
style=${styleMap({
top: this._tooltip.top,
left: this._tooltip.left,
})}
>
<div class="title">${this._tooltip.title}</div>
${this._tooltip.beforeBody
? html`<div class="beforeBody">
${this._tooltip.beforeBody}
</div>`
: ""}
<div>
<ul>
${this._tooltip.body.map(
(item, i) => html`<li>
<div
class="bullet"
style=${styleMap({
backgroundColor: this._tooltip!.labelColors[i]
.backgroundColor as string,
borderColor: this._tooltip!.labelColors[i]
.borderColor as string,
})}
></div>
${item.lines.join("\n")}
</li>`
)}
</ul>
</div>
</div>`
: ""}
</div>
`;
}
private async _setupChart() {
const ctx: CanvasRenderingContext2D = this.renderRoot
.querySelector("canvas")!
.getContext("2d")!;
this.chart = new (await import("../../resources/chartjs")).Chart(ctx, {
type: this.chartType,
data: this.data,
options: this._createOptions(),
plugins: [
{
id: "afterRenderHook",
afterRender: (chart) => {
this._height = `${chart.height}px`;
},
},
],
});
}
private _createOptions() {
return {
...this.options,
plugins: {
...this.options?.plugins,
tooltip: {
...this.options?.plugins?.tooltip,
enabled: false,
external: (context) => this._handleTooltip(context),
},
legend: {
...this.options?.plugins?.legend,
display: false,
},
},
};
}
private _legendClick(ev) {
if (!this.chart) {
return;
}
const index = ev.currentTarget.datasetIndex;
if (this.chart.isDatasetVisible(index)) {
this.chart.setDatasetVisibility(index, false);
this._hiddenDatasets.add(index);
} else {
this.chart.setDatasetVisibility(index, true);
this._hiddenDatasets.delete(index);
}
this.chart.update("none");
this.requestUpdate("_hiddenDatasets");
}
private _handleTooltip(context: {
chart: Chart;
tooltip: TooltipModel<any>;
}) {
if (context.tooltip.opacity === 0) {
this._tooltip = undefined;
return;
}
this._tooltip = {
...context.tooltip,
top: this.chart!.canvas.offsetTop + context.tooltip.caretY + 12 + "px",
left:
this.chart!.canvas.offsetLeft +
clamp(context.tooltip.caretX, 100, this.clientWidth - 100) -
100 +
"px",
};
}
public updateChart = (): void => {
if (this.chart) {
this.chart.update();
}
};
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
}
.chartContainer {
overflow: hidden;
height: 0;
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chartLegend {
text-align: center;
}
.chartLegend li {
cursor: pointer;
display: inline-flex;
padding: 0 8px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
align-items: center;
color: var(--secondary-text-color);
}
.chartLegend .hidden {
text-decoration: line-through;
}
.chartLegend .bullet,
.chartTooltip .bullet {
border-width: 1px;
border-style: solid;
border-radius: 50%;
display: inline-block;
height: 16px;
margin-right: 4px;
width: 16px;
flex-shrink: 0;
box-sizing: border-box;
}
.chartTooltip .bullet {
align-self: baseline;
}
:host([rtl]) .chartTooltip .bullet {
margin-right: inherit;
margin-left: 4px;
}
.chartTooltip {
padding: 8px;
font-size: 90%;
position: absolute;
background: rgba(80, 80, 80, 0.9);
color: white;
border-radius: 4px;
pointer-events: none;
z-index: 1000;
width: 200px;
box-sizing: border-box;
}
:host([rtl]) .chartTooltip {
direction: rtl;
}
.chartLegend ul,
.chartTooltip ul {
display: inline-block;
padding: 0 0px;
margin: 8px 0 0 0;
width: 100%;
}
.chartTooltip ul {
margin: 0 4px;
}
.chartTooltip li {
display: flex;
white-space: pre-line;
align-items: center;
line-height: 16px;
}
.chartTooltip .title {
text-align: center;
font-weight: 500;
}
.chartTooltip .beforeBody {
text-align: center;
font-weight: 300;
word-break: break-all;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-base": HaChartBase;
}
}
@@ -0,0 +1,392 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { html, LitElement, PropertyValues } from "lit";
import { property, state } from "lit/decorators";
import { getColorByIndex } from "../../common/color/colors";
import { LineChartEntity, LineChartState } from "../../data/history";
import { HomeAssistant } from "../../types";
import "./ha-chart-base";
class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: LineChartEntity[] = [];
@property({ type: Boolean }) public names = false;
@property() public unit?: string;
@property() public identifier?: string;
@property({ type: Boolean }) public isSingleDevice = false;
@property({ attribute: false }) public endTime?: Date;
@state() private _chartData?: ChartData<"line">;
@state() private _chartOptions?: ChartOptions<"line">;
protected render() {
return html`
<ha-chart-base
.data=${this._chartData}
.options=${this._chartOptions}
chartType="line"
></ha-chart-base>
`;
}
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._chartOptions = {
parsing: false,
animation: false,
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetimeseconds",
},
},
y: {
ticks: {
maxTicksLimit: 7,
},
title: {
display: true,
text: this.unit,
},
},
},
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
label: (context) =>
`${context.dataset.label}: ${context.parsed.y} ${this.unit}`,
},
},
filler: {
propagate: true,
},
legend: {
display: !this.isSingleDevice,
labels: {
usePointStyle: true,
},
},
},
hover: {
mode: "nearest",
},
elements: {
line: {
tension: 0.1,
borderWidth: 1.5,
},
point: {
hitRadius: 5,
},
},
};
}
if (changedProps.has("data")) {
this._generateData();
}
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
const deviceStates = this.data;
const datasets: ChartDataset<"line">[] = [];
let endTime: Date;
if (deviceStates.length === 0) {
return;
}
function safeParseFloat(value) {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
}
endTime =
this.endTime ||
// Get the highest date from the last date of each device
new Date(
Math.max.apply(
null,
deviceStates.map((devSts) =>
new Date(
devSts.states[devSts.states.length - 1].last_changed
).getMilliseconds()
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const names = this.names || {};
deviceStates.forEach((states) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: ChartDataset<"line">[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
data.forEach((d, i) => {
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
// null data values show up as gaps in the chart.
// If the current value for the dataset is null and the previous
// value of the data set is not null, then add an 'end' point
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data.push({ x: timestamp.getTime(), y: prevValues[i] });
}
d.data.push({ x: timestamp.getTime(), y: datavalues[i] });
});
prevValues = datavalues;
};
const addDataSet = (
nameY: string,
step = false,
fill = false,
color?: string
) => {
if (!color) {
color = getColorByIndex(colorIndex);
colorIndex++;
}
data.push({
label: nameY,
fill: fill ? "origin" : false,
borderColor: color,
backgroundColor: color + "7F",
stepped: step ? "before" : false,
pointRadius: 0,
data: [],
});
};
if (
domain === "thermostat" ||
domain === "climate" ||
domain === "water_heater"
) {
const hasHvacAction = states.states.some(
(entityState) => entityState.attributes?.hvac_action
);
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
entityState.attributes?.hvac_action === "heating"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
entityState.attributes?.hvac_action === "cooling"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
const hasCool = states.states.some(isCooling);
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(
(entityState) =>
entityState.attributes &&
entityState.attributes.target_temp_high !==
entityState.attributes.target_temp_low
);
addDataSet(
`${this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})}`,
true
);
if (hasHeat) {
addDataSet(
`${this.hass.localize("ui.card.climate.heating", { name: name })}`,
true,
true,
computedStyles.getPropertyValue("--state-climate-heat-color")
);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasCool) {
addDataSet(
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`,
true,
true,
computedStyles.getPropertyValue("--state-climate-cool-color")
);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasTargetRange) {
addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})}`,
true
);
addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})}`,
true
);
} else {
addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_entity", {
name: name,
})}`,
true
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const curTemp = safeParseFloat(
entityState.attributes.current_temperature
);
const series = [curTemp];
if (hasHeat) {
series.push(isHeating(entityState) ? curTemp : null);
}
if (hasCool) {
series.push(isCooling(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
entityState.attributes.target_temp_high
);
const targetLow = safeParseFloat(
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(new Date(entityState.last_changed), series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(new Date(entityState.last_changed), series);
}
});
} else if (domain === "humidifier") {
addDataSet(
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})}`,
true
);
addDataSet(
`${this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})}`,
true,
true
);
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const target = safeParseFloat(entityState.attributes.humidity);
const series = [target];
series.push(entityState.state === "on" ? target : null);
pushData(new Date(entityState.last_changed), series);
});
} else {
// Only disable interpolation for sensors
const isStep = domain === "sensor";
addDataSet(name, isStep);
let lastValue: number;
let lastDate: Date;
let lastNullDate: Date | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
states.states.forEach((entityState) => {
const value = safeParseFloat(entityState.state);
const date = new Date(entityState.last_changed);
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (
value === null &&
lastNullDate === null &&
lastValue !== undefined
) {
lastNullDate = date;
}
});
}
// Add an entry for final values
pushData(endTime, prevValues);
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
this._chartData = {
datasets,
};
}
}
customElements.define("state-history-chart-line", StateHistoryChartLine);
declare global {
interface HTMLElementTagNameMap {
"state-history-chart-line": StateHistoryChartLine;
}
}
@@ -0,0 +1,310 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { getColorByIndex } from "../../common/color/colors";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeRTL } from "../../common/util/compute_rtl";
import { TimelineEntity } from "../../data/history";
import { HomeAssistant } from "../../types";
import "./ha-chart-base";
import type { TimeLineData } from "./timeline-chart/const";
/** Binary sensor device classes for which the static colors for on/off need to be inverted.
* List the ones were "off" = good or normal state = should be rendered "green".
*/
const BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED = new Set([
"battery",
"door",
"garage_door",
"gas",
"lock",
"opening",
"problem",
"safety",
"smoke",
"window",
]);
const STATIC_STATE_COLORS = new Set([
"on",
"off",
"home",
"not_home",
"unavailable",
"unknown",
"idle",
]);
const stateColorMap: Map<string, string> = new Map();
let colorIndex = 0;
const invertOnOff = (entityState?: HassEntity) =>
entityState &&
computeDomain(entityState.entity_id) === "binary_sensor" &&
"device_class" in entityState.attributes &&
BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED.has(
entityState.attributes.device_class!
);
const getColor = (
stateString: string,
entityState: HassEntity,
computedStyles: CSSStyleDeclaration
) => {
if (invertOnOff(entityState)) {
stateString = stateString === "on" ? "off" : "on";
}
if (stateColorMap.has(stateString)) {
return stateColorMap.get(stateString);
}
if (STATIC_STATE_COLORS.has(stateString)) {
const color = computedStyles.getPropertyValue(
`--state-${stateString}-color`
);
stateColorMap.set(stateString, color);
return color;
}
const color = getColorByIndex(colorIndex);
colorIndex++;
stateColorMap.set(stateString, color);
return color;
};
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: TimelineEntity[] = [];
@property({ type: Boolean }) public names = false;
@property() public unit?: string;
@property() public identifier?: string;
@property({ type: Boolean }) public isSingleDevice = false;
@property({ attribute: false }) public endTime?: Date;
@state() private _chartData?: ChartData<"timeline">;
@state() private _chartOptions?: ChartOptions<"timeline">;
protected render() {
return html`
<ha-chart-base
.data=${this._chartData}
.options=${this._chartOptions}
chartType="timeline"
></ha-chart-base>
`;
}
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._chartOptions = {
maintainAspectRatio: false,
parsing: false,
animation: false,
scales: {
x: {
type: "timeline",
position: "bottom",
adapters: {
date: {
locale: this.hass.locale,
},
},
ticks: {
autoSkip: true,
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
grid: {
offset: false,
},
time: {
tooltipFormat: "datetimeseconds",
},
},
y: {
type: "category",
barThickness: 20,
offset: true,
grid: {
display: false,
drawBorder: false,
drawTicks: false,
},
ticks: {
display: this.data.length !== 1,
},
afterSetDimensions: (y) => {
y.maxWidth = y.chart.width * 0.18;
},
position: computeRTL(this.hass) ? "right" : "left",
},
},
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
title: (context) =>
context![0].chart!.data!.labels![
context[0].datasetIndex
] as string,
beforeBody: (context) => context[0].dataset.label || "",
label: (item) => {
const d = item.dataset.data[item.dataIndex] as TimeLineData;
return [
d.label || "",
formatDateTimeWithSeconds(d.start, this.hass.locale),
formatDateTimeWithSeconds(d.end, this.hass.locale),
];
},
labelColor: (item) => ({
borderColor: (item.dataset.data[item.dataIndex] as TimeLineData)
.color!,
backgroundColor: (item.dataset.data[
item.dataIndex
] as TimeLineData).color!,
}),
},
},
filler: {
propagate: true,
},
},
};
}
if (changedProps.has("data")) {
this._generateData();
}
}
private _generateData() {
const computedStyles = getComputedStyle(this);
let stateHistory = this.data;
if (!stateHistory) {
stateHistory = [];
}
const startTime = new Date(
stateHistory.reduce(
(minTime, stateInfo) =>
Math.min(minTime, new Date(stateInfo.data[0].last_changed).getTime()),
new Date().getTime()
)
);
// end time is Math.max(startTime, last_event)
let endTime =
this.endTime ||
new Date(
stateHistory.reduce(
(maxTime, stateInfo) =>
Math.max(
maxTime,
new Date(
stateInfo.data[stateInfo.data.length - 1].last_changed
).getTime()
),
startTime.getTime()
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const labels: string[] = [];
const datasets: ChartDataset<"timeline">[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let newLastChanged: Date;
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
const dataRow: TimeLineData[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
if (!newState) {
newState = null;
}
if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = new Date(entityState.last_changed);
} else if (newState !== prevState) {
newLastChanged = new Date(entityState.last_changed);
dataRow.push({
start: prevLastChanged,
end: newLastChanged,
label: locState,
color: getColor(
prevState,
this.hass.states[stateInfo.entity_id],
computedStyles
),
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = newLastChanged;
}
});
if (prevState !== null) {
dataRow.push({
start: prevLastChanged,
end: endTime,
label: locState,
color: getColor(
prevState,
this.hass.states[stateInfo.entity_id],
computedStyles
),
});
}
datasets.push({
data: dataRow,
label: stateInfo.entity_id,
});
labels.push(entityDisplay);
});
this._chartData = {
labels: labels,
datasets: datasets,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"state-history-chart-timeline": StateHistoryChartTimeline;
}
}
@@ -7,10 +7,10 @@ import {
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { HistoryResult } from "../data/history";
import type { HomeAssistant } from "../types";
import "./ha-circular-progress";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { HistoryResult } from "../../data/history";
import type { HomeAssistant } from "../../types";
import "../ha-circular-progress";
import "./state-history-chart-line";
import "./state-history-chart-timeline";
@@ -24,7 +24,7 @@ class StateHistoryCharts extends LitElement {
@property({ attribute: false }) public endTime?: Date;
@property({ type: Boolean }) public upToNow = false;
@property({ type: Boolean, attribute: "up-to-now" }) public upToNow = false;
@property({ type: Boolean, attribute: "no-single" }) public noSingle = false;
@@ -101,12 +101,12 @@ class StateHistoryCharts extends LitElement {
return css`
:host {
display: block;
/* height of single timeline chart = 58px */
min-height: 58px;
/* height of single timeline chart = 60px */
min-height: 60px;
}
.info {
text-align: center;
line-height: 58px;
line-height: 60px;
color: var(--secondary-text-color);
}
`;
@@ -0,0 +1,18 @@
export interface TimeLineData {
start: Date;
end: Date;
label?: string | null;
color?: string;
}
declare module "chart.js" {
interface ChartTypeRegistry {
timeline: {
chartOptions: BarControllerChartOptions;
datasetOptions: BarControllerDatasetOptions;
defaultDataPoint: TimeLineData;
parsedDataType: any;
scales: "timeline";
};
}
}
@@ -0,0 +1,60 @@
import { BarElement, BarOptions, BarProps } from "chart.js";
import { hex2rgb } from "../../../common/color/convert-color";
import { luminosity } from "../../../common/color/rgb";
export interface TextBarProps extends BarProps {
text?: string | null;
options?: Partial<TextBaroptions>;
}
export interface TextBaroptions extends BarOptions {
textPad?: number;
textColor?: string;
backgroundColor: string;
}
export class TextBarElement extends BarElement {
static id = "textbar";
draw(ctx) {
super.draw(ctx);
const options = this.options as TextBaroptions;
const { x, y, base, width, text } = (this as BarElement<
TextBarProps,
TextBaroptions
>).getProps(["x", "y", "base", "width", "text"]);
if (!text) {
return;
}
ctx.beginPath();
const textRect = ctx.measureText(text);
if (
textRect.width === 0 ||
textRect.width + (options.textPad || 4) + 2 > width
) {
return;
}
const textColor =
options.textColor ||
(options.backgroundColor &&
(luminosity(hex2rgb(options.backgroundColor)) > 0.5 ? "#000" : "#fff"));
// ctx.font = "12px arial";
ctx.fillStyle = textColor;
ctx.lineWidth = 0;
ctx.strokeStyle = textColor;
ctx.textBaseline = "middle";
ctx.fillText(
text,
x - width / 2 + (options.textPad || 4),
y + (base - y) / 2
);
}
tooltipPosition(useFinalPosition: boolean) {
const { x, y, base } = this.getProps(["x", "y", "base"], useFinalPosition);
return { x, y: y + (base - y) / 2 };
}
}
@@ -0,0 +1,160 @@
import { BarController, BarElement } from "chart.js";
import { TimeLineData } from "./const";
import { TextBarProps } from "./textbar-element";
function parseValue(entry, item, vScale, i) {
const startValue = vScale.parse(entry.start, i);
const endValue = vScale.parse(entry.end, i);
const min = Math.min(startValue, endValue);
const max = Math.max(startValue, endValue);
let barStart = min;
let barEnd = max;
if (Math.abs(min) > Math.abs(max)) {
barStart = max;
barEnd = min;
}
// Store `barEnd` (furthest away from origin) as parsed value,
// to make stacking straight forward
item[vScale.axis] = barEnd;
item._custom = {
barStart,
barEnd,
start: startValue,
end: endValue,
min,
max,
};
return item;
}
export class TimelineController extends BarController {
static id = "timeline";
static defaults = {
dataElementType: "textbar",
dataElementOptions: ["text", "textColor", "textPadding"],
elements: {
showText: true,
textPadding: 4,
minBarWidth: 1,
},
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
};
static overrides = {
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
};
parseObjectData(meta, data, start, count) {
const iScale = meta.iScale;
const vScale = meta.vScale;
const labels = iScale.getLabels();
const singleScale = iScale === vScale;
const parsed: any[] = [];
let i;
let ilen;
let item;
let entry;
for (i = start, ilen = start + count; i < ilen; ++i) {
entry = data[i];
item = {};
item[iScale.axis] = singleScale || iScale.parse(labels[i], i);
parsed.push(parseValue(entry, item, vScale, i));
}
return parsed;
}
getLabelAndValue(index) {
const meta = this._cachedMeta;
const { vScale } = meta;
const data = this.getDataset().data[index] as TimeLineData;
return {
label: vScale!.getLabelForValue(this.index) || "",
value: data.label || "",
};
}
updateElements(
bars: BarElement[],
start: number,
count: number,
mode: "reset" | "resize" | "none" | "hide" | "show" | "normal" | "active"
) {
const vScale = this._cachedMeta.vScale!;
const iScale = this._cachedMeta.iScale!;
const dataset = this.getDataset();
const firstOpts = this.resolveDataElementOptions(start, mode);
const sharedOptions = this.getSharedOptions(firstOpts);
const includeOptions = this.includeOptions(mode, sharedOptions!);
const horizontal = vScale.isHorizontal();
this.updateSharedOptions(sharedOptions!, mode, firstOpts);
for (let index = start; index < start + count; index++) {
const data = dataset.data[index] as TimeLineData;
// @ts-ignore
const y = vScale.getPixelForValue(this.index);
// @ts-ignore
const xStart = iScale.getPixelForValue(data.start.getTime());
// @ts-ignore
const xEnd = iScale.getPixelForValue(data.end.getTime());
const width = xEnd - xStart;
const height = 10;
const properties: TextBarProps = {
horizontal,
x: xStart + width / 2, // Center of the bar
y: y - height, // Top of bar
width,
height: 0,
base: y + height, // Bottom of bar,
// Text
text: data.label,
};
if (includeOptions) {
properties.options =
sharedOptions || this.resolveDataElementOptions(index, mode);
properties.options = {
...properties.options,
backgroundColor: data.color,
};
}
this.updateElement(bars[index], index, properties as any, mode);
}
}
removeHoverStyle(_element, _datasetIndex, _index) {
// this._setStyle(element, index, 'active', false);
}
setHoverStyle(_element, _datasetIndex, _index) {
// this._setStyle(element, index, 'active', true);
}
}
@@ -0,0 +1,55 @@
import { TimeScale } from "chart.js";
import { TimeLineData } from "./const";
export class TimeLineScale extends TimeScale {
static id = "timeline";
static defaults = {
position: "bottom",
tooltips: {
mode: "nearest",
},
ticks: {
autoSkip: true,
},
};
determineDataLimits() {
const options = this.options;
// @ts-ignore
const adapter = this._adapter;
const unit = options.time.unit || "day";
let { min, max } = this.getUserBounds();
const chart = this.chart;
// Convert data to timestamps
chart.data.datasets.forEach((dataset, index) => {
if (!chart.isDatasetVisible(index)) {
return;
}
for (const data of dataset.data as TimeLineData[]) {
let timestamp0 = adapter.parse(data.start, this);
let timestamp1 = adapter.parse(data.end, this);
if (timestamp0 > timestamp1) {
[timestamp0, timestamp1] = [timestamp1, timestamp0];
}
if (min > timestamp0 && timestamp0) {
min = timestamp0;
}
if (max < timestamp1 && timestamp1) {
max = timestamp1;
}
}
});
// In case there is no valid min/max, var's use today limits
min =
isFinite(min) && !isNaN(min) ? min : +adapter.startOf(Date.now(), unit);
max = isFinite(max) && !isNaN(max) ? max : +adapter.endOf(Date.now(), unit);
// Make sure that max is strictly higher than min (required by the lookup table)
this.min = Math.min(min, max - 1);
this.max = Math.max(min + 1, max);
}
}
-661
View File
@@ -1,661 +0,0 @@
/* eslint-plugin-disable lit */
import { IronResizableBehavior } from "@polymer/iron-resizable-behavior/iron-resizable-behavior";
import { mixinBehaviors } from "@polymer/polymer/lib/legacy/class";
import { timeOut } from "@polymer/polymer/lib/utils/async";
import { Debouncer } from "@polymer/polymer/lib/utils/debounce";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { formatTime } from "../../common/datetime/format_time";
import "../ha-icon-button";
// eslint-disable-next-line no-unused-vars
/* global Chart moment Color */
let scriptsLoaded = null;
class HaChartBase extends mixinBehaviors(
[IronResizableBehavior],
PolymerElement
) {
static get template() {
return html`
<style>
:host {
display: block;
}
.chartHeader {
padding: 6px 0 0 0;
width: 100%;
display: flex;
flex-direction: row;
}
.chartHeader > div {
vertical-align: top;
padding: 0 8px;
}
.chartHeader > div.chartTitle {
padding-top: 8px;
flex: 0 0 0;
max-width: 30%;
}
.chartHeader > div.chartLegend {
flex: 1 1;
min-width: 70%;
}
:root {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.chartTooltip {
font-size: 90%;
opacity: 1;
position: absolute;
background: rgba(80, 80, 80, 0.9);
color: white;
border-radius: 3px;
pointer-events: none;
transform: translate(-50%, 12px);
z-index: 1000;
width: 200px;
transition: opacity 0.15s ease-in-out;
}
:host([rtl]) .chartTooltip {
direction: rtl;
}
.chartLegend ul,
.chartTooltip ul {
display: inline-block;
padding: 0 0px;
margin: 5px 0 0 0;
width: 100%;
}
.chartTooltip ul {
margin: 0 3px;
}
.chartTooltip li {
display: block;
white-space: pre-line;
}
.chartTooltip li::first-line {
line-height: 0;
}
.chartTooltip .title {
text-align: center;
font-weight: 500;
}
.chartTooltip .beforeBody {
text-align: center;
font-weight: 300;
word-break: break-all;
}
.chartLegend li {
display: inline-block;
padding: 0 6px;
max-width: 49%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
}
.chartLegend li:nth-child(odd):last-of-type {
/* Make last item take full width if it is odd-numbered. */
max-width: 100%;
}
.chartLegend li[data-hidden] {
text-decoration: line-through;
}
.chartLegend em,
.chartTooltip em {
border-radius: 5px;
display: inline-block;
height: 10px;
margin-right: 4px;
width: 10px;
}
:host([rtl]) .chartTooltip em {
margin-right: inherit;
margin-left: 4px;
}
ha-icon-button {
color: var(--secondary-text-color);
}
</style>
<template is="dom-if" if="[[unit]]">
<div class="chartHeader">
<div class="chartTitle">[[unit]]</div>
<div class="chartLegend">
<ul>
<template is="dom-repeat" items="[[metas]]">
<li on-click="_legendClick" data-hidden$="[[item.hidden]]">
<em style$="background-color:[[item.bgColor]]"></em>
[[item.label]]
</li>
</template>
</ul>
</div>
</div>
</template>
<div id="chartTarget" style="height:40px; width:100%">
<canvas id="chartCanvas"></canvas>
<div
class$="chartTooltip [[tooltip.yAlign]]"
style$="opacity:[[tooltip.opacity]]; top:[[tooltip.top]]; left:[[tooltip.left]]; padding:[[tooltip.yPadding]]px [[tooltip.xPadding]]px"
>
<div class="title">[[tooltip.title]]</div>
<template is="dom-if" if="[[tooltip.beforeBody]]">
<div class="beforeBody">[[tooltip.beforeBody]]</div>
</template>
<div>
<ul>
<template is="dom-repeat" items="[[tooltip.lines]]">
<li>
<em style$="background-color:[[item.bgColor]]"></em
>[[item.text]]
</li>
</template>
</ul>
</div>
</div>
</div>
`;
}
get chart() {
return this._chart;
}
static get properties() {
return {
data: Object,
identifier: String,
rendered: {
type: Boolean,
notify: true,
value: false,
readOnly: true,
},
metas: {
type: Array,
value: () => [],
},
tooltip: {
type: Object,
value: () => ({
opacity: "0",
left: "0",
top: "0",
xPadding: "5",
yPadding: "3",
}),
},
unit: Object,
rtl: {
type: Boolean,
reflectToAttribute: true,
},
};
}
static get observers() {
return ["onPropsChange(data)"];
}
connectedCallback() {
super.connectedCallback();
this._isAttached = true;
this.onPropsChange();
this._resizeListener = () => {
this._debouncer = Debouncer.debounce(
this._debouncer,
timeOut.after(10),
() => {
if (this._isAttached) {
this.resizeChart();
}
}
);
};
if (typeof ResizeObserver === "function") {
this.resizeObserver = new ResizeObserver((entries) => {
entries.forEach(() => {
this._resizeListener();
});
});
this.resizeObserver.observe(this.$.chartTarget);
} else {
this.addEventListener("iron-resize", this._resizeListener);
}
if (scriptsLoaded === null) {
scriptsLoaded = import("../../resources/ha-chart-scripts.js");
}
scriptsLoaded.then((ChartModule) => {
this.ChartClass = ChartModule.default;
this.onPropsChange();
});
}
disconnectedCallback() {
super.disconnectedCallback();
this._isAttached = false;
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.$.chartTarget);
}
this.removeEventListener("iron-resize", this._resizeListener);
if (this._resizeTimer !== undefined) {
clearInterval(this._resizeTimer);
this._resizeTimer = undefined;
}
}
onPropsChange() {
if (!this._isAttached || !this.ChartClass || !this.data) {
return;
}
this.drawChart();
}
_customTooltips(tooltip) {
// Hide if no tooltip
if (tooltip.opacity === 0) {
this.set(["tooltip", "opacity"], 0);
return;
}
// Set caret Position
if (tooltip.yAlign) {
this.set(["tooltip", "yAlign"], tooltip.yAlign);
} else {
this.set(["tooltip", "yAlign"], "no-transform");
}
const title = tooltip.title ? tooltip.title[0] || "" : "";
this.set(["tooltip", "title"], title);
if (tooltip.beforeBody) {
this.set(["tooltip", "beforeBody"], tooltip.beforeBody.join("\n"));
}
const bodyLines = tooltip.body.map((n) => n.lines);
// Set Text
if (tooltip.body) {
this.set(
["tooltip", "lines"],
bodyLines.map((body, i) => {
const colors = tooltip.labelColors[i];
return {
color: colors.borderColor,
bgColor: colors.backgroundColor,
text: body.join("\n"),
};
})
);
}
const parentWidth = this.$.chartTarget.clientWidth;
let positionX = tooltip.caretX;
const positionY = this._chart.canvas.offsetTop + tooltip.caretY;
if (tooltip.caretX + 100 > parentWidth) {
positionX = parentWidth - 100;
} else if (tooltip.caretX < 100) {
positionX = 100;
}
positionX += this._chart.canvas.offsetLeft;
// Display, position, and set styles for font
this.tooltip = {
...this.tooltip,
opacity: 1,
left: `${positionX}px`,
top: `${positionY}px`,
};
}
_legendClick(event) {
event = event || window.event;
event.stopPropagation();
let target = event.target || event.srcElement;
while (target.nodeName !== "LI") {
// user clicked child, find parent LI
target = target.parentElement;
}
const index = event.model.itemsIndex;
const meta = this._chart.getDatasetMeta(index);
meta.hidden =
meta.hidden === null ? !this._chart.data.datasets[index].hidden : null;
this.set(
["metas", index, "hidden"],
this._chart.isDatasetVisible(index) ? null : "hidden"
);
this._chart.update();
}
_drawLegend() {
const chart = this._chart;
// New data for old graph. Keep metadata.
const preserveVisibility =
this._oldIdentifier && this.identifier === this._oldIdentifier;
this._oldIdentifier = this.identifier;
this.set(
"metas",
this._chart.data.datasets.map((x, i) => ({
label: x.label,
color: x.color,
bgColor: x.backgroundColor,
hidden:
preserveVisibility && i < this.metas.length
? this.metas[i].hidden
: !chart.isDatasetVisible(i),
}))
);
let updateNeeded = false;
if (preserveVisibility) {
for (let i = 0; i < this.metas.length; i++) {
const meta = chart.getDatasetMeta(i);
if (!!meta.hidden !== !!this.metas[i].hidden) updateNeeded = true;
meta.hidden = this.metas[i].hidden ? true : null;
}
}
if (updateNeeded) {
chart.update();
}
this.unit = this.data.unit;
}
_formatTickValue(value, index, values) {
if (values.length === 0) {
return value;
}
const date = new Date(values[index].value);
return formatTime(date, this.hass.locale);
}
drawChart() {
const data = this.data.data;
const ctx = this.$.chartCanvas;
if ((!data.datasets || !data.datasets.length) && !this._chart) {
return;
}
if (this.data.type !== "timeline" && data.datasets.length > 0) {
const cnt = data.datasets.length;
const colors = this.constructor.getColorList(cnt);
for (let loopI = 0; loopI < cnt; loopI++) {
data.datasets[loopI].borderColor = colors[loopI].rgbString();
data.datasets[loopI].backgroundColor = colors[loopI]
.alpha(0.6)
.rgbaString();
}
}
if (this._chart) {
this._customTooltips({ opacity: 0 });
this._chart.data = data;
this._chart.update({ duration: 0 });
if (this.isTimeline) {
this._chart.options.scales.yAxes[0].gridLines.display = data.length > 1;
} else if (this.data.legend === true) {
this._drawLegend();
}
this.resizeChart();
} else {
if (!data.datasets) {
return;
}
this._customTooltips({ opacity: 0 });
const plugins = [{ afterRender: () => this._setRendered(true) }];
let options = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0,
},
hover: {
animationDuration: 0,
},
responsiveAnimationDuration: 0,
tooltips: {
enabled: false,
custom: this._customTooltips.bind(this),
},
legend: {
display: false,
},
line: {
spanGaps: true,
},
elements: {
font: "12px 'Roboto', 'sans-serif'",
},
ticks: {
fontFamily: "'Roboto', 'sans-serif'",
},
};
options = Chart.helpers.merge(options, this.data.options);
options.scales.xAxes[0].ticks.callback = this._formatTickValue.bind(this);
if (this.data.type === "timeline") {
this.set("isTimeline", true);
if (this.data.colors !== undefined) {
this._colorFunc = this.constructor.getColorGenerator(
this.data.colors.staticColors,
this.data.colors.staticColorIndex
);
}
if (this._colorFunc !== undefined) {
options.elements.colorFunction = this._colorFunc;
}
if (data.datasets.length === 1) {
if (options.scales.yAxes[0].ticks) {
options.scales.yAxes[0].ticks.display = false;
} else {
options.scales.yAxes[0].ticks = { display: false };
}
if (options.scales.yAxes[0].gridLines) {
options.scales.yAxes[0].gridLines.display = false;
} else {
options.scales.yAxes[0].gridLines = { display: false };
}
}
this.$.chartTarget.style.height = "50px";
} else {
this.$.chartTarget.style.height = "160px";
}
const chartData = {
type: this.data.type,
data: this.data.data,
options: options,
plugins: plugins,
};
// Async resize after dom update
this._chart = new this.ChartClass(ctx, chartData);
if (this.isTimeline !== true && this.data.legend === true) {
this._drawLegend();
}
this.resizeChart();
}
}
resizeChart() {
if (!this._chart) return;
// Chart not ready
if (this._resizeTimer === undefined) {
this._resizeTimer = setInterval(this.resizeChart.bind(this), 10);
return;
}
clearInterval(this._resizeTimer);
this._resizeTimer = undefined;
this._resizeChart();
}
_resizeChart() {
const chartTarget = this.$.chartTarget;
const options = this.data;
const data = options.data;
if (data.datasets.length === 0) {
return;
}
if (!this.isTimeline) {
this._chart.resize();
return;
}
// Recalculate chart height for Timeline chart
const areaTop = this._chart.chartArea.top;
const areaBot = this._chart.chartArea.bottom;
const height1 = this._chart.canvas.clientHeight;
if (areaBot > 0) {
this._axisHeight = height1 - areaBot + areaTop;
}
if (!this._axisHeight) {
chartTarget.style.height = "50px";
this._chart.resize();
this.resizeChart();
return;
}
if (this._axisHeight) {
const cnt = data.datasets.length;
const targetHeight = 30 * cnt + this._axisHeight + "px";
if (chartTarget.style.height !== targetHeight) {
chartTarget.style.height = targetHeight;
}
this._chart.resize();
}
}
// Get HSL distributed color list
static getColorList(count) {
let processL = false;
if (count > 10) {
processL = true;
count = Math.ceil(count / 2);
}
const h1 = 360 / count;
const result = [];
for (let loopI = 0; loopI < count; loopI++) {
result[loopI] = Color().hsl(h1 * loopI, 80, 38);
if (processL) {
result[loopI + count] = Color().hsl(h1 * loopI, 80, 62);
}
}
return result;
}
static getColorGenerator(staticColors, startIndex) {
// Known colors for static data,
// should add for very common state string manually.
// Palette modified from http://google.github.io/palette.js/ mpn65, Apache 2.0
const palette = [
"ff0029",
"66a61e",
"377eb8",
"984ea3",
"00d2d5",
"ff7f00",
"af8d00",
"7f80cd",
"b3e900",
"c42e60",
"a65628",
"f781bf",
"8dd3c7",
"bebada",
"fb8072",
"80b1d3",
"fdb462",
"fccde5",
"bc80bd",
"ffed6f",
"c4eaff",
"cf8c00",
"1b9e77",
"d95f02",
"e7298a",
"e6ab02",
"a6761d",
"0097ff",
"00d067",
"f43600",
"4ba93b",
"5779bb",
"927acc",
"97ee3f",
"bf3947",
"9f5b00",
"f48758",
"8caed6",
"f2b94f",
"eff26e",
"e43872",
"d9b100",
"9d7a00",
"698cff",
"d9d9d9",
"00d27e",
"d06800",
"009f82",
"c49200",
"cbe8ff",
"fecddf",
"c27eb6",
"8cd2ce",
"c4b8d9",
"f883b0",
"a49100",
"f48800",
"27d0df",
"a04a9b",
];
function getColorIndex(idx) {
// Reuse the color if index too large.
return Color("#" + palette[idx % palette.length]);
}
const colorDict = {};
let colorIndex = 0;
if (startIndex > 0) colorIndex = startIndex;
if (staticColors) {
Object.keys(staticColors).forEach((c) => {
const c1 = staticColors[c];
if (isFinite(c1)) {
colorDict[c.toLowerCase()] = getColorIndex(c1);
} else {
colorDict[c.toLowerCase()] = Color(staticColors[c]);
}
});
}
// Custom color assign
function getColor(__, data) {
let ret;
const name = data[3];
if (name === null) return Color().hsl(0, 40, 38);
if (name === undefined) return Color().hsl(120, 40, 38);
let name1 = name.toLowerCase();
if (ret === undefined) {
if (data[4]) {
// Invert on/off if data[4] is true. Required for some binary_sensor device classes
// (BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED) where "off" is the good (= green color) value.
name1 = name1 === "on" ? "off" : name1 === "off" ? "on" : name1;
}
ret = colorDict[name1];
}
if (ret === undefined) {
ret = getColorIndex(colorIndex);
colorIndex++;
colorDict[name1] = ret;
}
return ret;
}
return getColor;
}
}
customElements.define("ha-chart-base", HaChartBase);
+49 -5
View File
@@ -1,14 +1,19 @@
import "@material/mwc-icon-button/mwc-icon-button";
import { mdiClose, mdiMenuDown } from "@mdi/js";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-menu-button/paper-menu-button";
import "@polymer/paper-ripple/paper-ripple";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-paper-dropdown-menu";
import "../ha-svg-icon";
import { HaFormElement, HaFormSelectData, HaFormSelectSchema } from "./ha-form";
@customElement("ha-form-select")
export class HaFormSelect extends LitElement implements HaFormElement {
@property() public schema!: HaFormSelectSchema;
@property({ attribute: false }) public schema!: HaFormSelectSchema;
@property() public data!: HaFormSelectData;
@@ -26,7 +31,33 @@ export class HaFormSelect extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
<ha-paper-dropdown-menu .label=${this.label}>
<paper-menu-button horizontal-align="right" vertical-offset="8">
<div class="dropdown-trigger" slot="dropdown-trigger">
<paper-ripple></paper-ripple>
<paper-input
id="input"
type="text"
readonly
value=${this.data}
label=${this.label}
input-role="button"
input-aria-haspopup="listbox"
autocomplete="off"
>
${this.data && this.schema.optional
? html`<mwc-icon-button
slot="suffix"
class="clear-button"
@click=${this._clearValue}
>
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>`
: ""}
<mwc-icon-button slot="suffix">
<ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
</mwc-icon-button>
</paper-input>
</div>
<paper-listbox
slot="dropdown-content"
attr-for-selected="item-value"
@@ -45,7 +76,7 @@ export class HaFormSelect extends LitElement implements HaFormElement {
)
}
</paper-listbox>
</ha-paper-dropdown-menu>
</paper-menu-button>
`;
}
@@ -57,6 +88,11 @@ export class HaFormSelect extends LitElement implements HaFormElement {
return Array.isArray(item) ? item[1] || item[0] : item;
}
private _clearValue(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: undefined });
}
private _valueChanged(ev: CustomEvent) {
if (!ev.detail.value) {
return;
@@ -68,8 +104,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
static get styles(): CSSResultGroup {
return css`
ha-paper-dropdown-menu {
paper-menu-button {
display: block;
padding: 0;
}
paper-input > mwc-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
}
.clear-button {
color: var(--secondary-text-color);
}
`;
}
+21 -29
View File
@@ -7,7 +7,7 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { nextRender } from "../common/util/render-status";
import { getExternalConfig } from "../external_app/external_config";
@@ -42,27 +42,23 @@ class HaHLSPlayer extends LitElement {
// don't cache this, as we remove it on disconnects
@query("video") private _videoEl!: HTMLVideoElement;
@state() private _attached = false;
private _hlsPolyfillInstance?: HlsLite;
private _useExoPlayer = false;
private _exoPlayer = false;
public connectedCallback() {
super.connectedCallback();
this._attached = true;
if (this.hasUpdated) {
this._startHls();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._attached = false;
this._cleanUp();
}
protected render(): TemplateResult {
if (!this._attached) {
return html``;
}
return html`
<video
?autoplay=${this.autoPlay}
@@ -77,21 +73,13 @@ class HaHLSPlayer extends LitElement {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
const attachedChanged = changedProps.has("_attached");
const urlChanged = changedProps.has("url");
if (!urlChanged && !attachedChanged) {
if (!urlChanged) {
return;
}
// If we are no longer attached, destroy polyfill
if (attachedChanged && !this._attached) {
// Tear down existing polyfill, if available
this._destroyPolyfill();
return;
}
this._destroyPolyfill();
this._cleanUp();
this._startHls();
}
@@ -118,13 +106,13 @@ class HaHLSPlayer extends LitElement {
}
if (!hlsSupported) {
this._videoEl.innerHTML = this.hass.localize(
videoEl.innerHTML = this.hass.localize(
"ui.components.media-browser.video_not_supported"
);
return;
}
this._useExoPlayer = await useExoPlayerPromise;
const useExoPlayer = await useExoPlayerPromise;
const masterPlaylist = await (await masterPlaylistPromise).text();
// Parse playlist assuming it is a master playlist. Match group 1 is whether hevc, match group 2 is regular playlist url
@@ -144,7 +132,7 @@ class HaHLSPlayer extends LitElement {
}
// If codec is HEVC and ExoPlayer is supported, use ExoPlayer.
if (this._useExoPlayer && match !== null && match[1] !== undefined) {
if (useExoPlayer && match !== null && match[1] !== undefined) {
this._renderHLSExoPlayer(playlist_url);
} else if (Hls.isSupported()) {
this._renderHLSPolyfill(videoEl, Hls, playlist_url);
@@ -154,6 +142,7 @@ class HaHLSPlayer extends LitElement {
}
private async _renderHLSExoPlayer(url: string) {
this._exoPlayer = true;
window.addEventListener("resize", this._resizeExoPlayer);
this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
this._videoEl.style.visibility = "hidden";
@@ -202,25 +191,28 @@ class HaHLSPlayer extends LitElement {
private async _renderHLSNative(videoEl: HTMLVideoElement, url: string) {
videoEl.src = url;
await new Promise((resolve) =>
videoEl.addEventListener("loadedmetadata", resolve)
);
videoEl.play();
videoEl.addEventListener("loadedmetadata", () => {
videoEl.play();
});
}
private _elementResized() {
fireEvent(this, "iron-resize");
}
private _destroyPolyfill() {
private _cleanUp() {
if (this._hlsPolyfillInstance) {
this._hlsPolyfillInstance.destroy();
this._hlsPolyfillInstance = undefined;
}
if (this._useExoPlayer) {
if (this._exoPlayer) {
window.removeEventListener("resize", this._resizeExoPlayer);
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
this._exoPlayer = false;
}
const videoEl = this._videoEl;
videoEl.removeAttribute("src");
videoEl.load();
}
static get styles(): CSSResultGroup {
+69
View File
@@ -0,0 +1,69 @@
import { LitElement, html, css } from "lit";
import { property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../common/dom/fire_event";
import { HomeAssistant } from "../../types";
class HaEntityMarker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId?: string;
@property({ attribute: "entity-name" }) public entityName?: string;
@property({ attribute: "entity-picture" }) public entityPicture?: string;
@property({ attribute: "entity-color" }) public entityColor?: string;
protected render() {
return html`
<div
class="marker"
style=${styleMap({ "border-color": this.entityColor })}
@click=${this._badgeTap}
>
${this.entityPicture
? html`<div
class="entity-picture"
style=${styleMap({
"background-image": `url(${this.entityPicture})`,
})}
></div>`
: this.entityName}
</div>
`;
}
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
fireEvent(this, "hass-more-info", { entityId: this.entityId });
}
}
static get styles() {
return css`
.marker {
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
overflow: hidden;
width: 48px;
height: 48px;
font-size: var(--ha-marker-font-size, 1.5em);
border-radius: 50%;
border: 1px solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.entity-picture {
background-size: cover;
height: 100%;
width: 100%;
}
`;
}
}
customElements.define("ha-entity-marker", HaEntityMarker);
-299
View File
@@ -1,299 +0,0 @@
import {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LeafletMouseEvent,
Map,
Marker,
TileLayer,
} from "leaflet";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import {
LeafletModuleType,
replaceTileLayer,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import { nextRender } from "../../common/util/render-status";
import { defaultRadiusColor } from "../../data/zone";
import { HomeAssistant } from "../../types";
@customElement("ha-location-editor")
class LocationEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Array }) public location?: [number, number];
@property({ type: Number }) public radius?: number;
@property() public radiusColor?: string;
@property() public icon?: string;
@property({ type: Boolean }) public darkMode?: boolean;
public fitZoom = 16;
private _iconEl?: DivIcon;
private _ignoreFitToMap?: [number, number];
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _tileLayer?: TileLayer;
private _locationMarker?: Marker | Circle;
public fitMap(): void {
if (!this._leafletMap || !this.location) {
return;
}
if (this._locationMarker && "getBounds" in this._locationMarker) {
this._leafletMap.fitBounds(this._locationMarker.getBounds());
} else {
this._leafletMap.setView(this.location, this.fitZoom);
}
this._ignoreFitToMap = this.location;
}
protected render(): TemplateResult {
return html` <div id="map"></div> `;
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this._initMap();
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
// Still loading.
if (!this.Leaflet) {
return;
}
if (changedProps.has("location")) {
this._updateMarker();
if (
this.location &&
(!this._ignoreFitToMap ||
this._ignoreFitToMap[0] !== this.location[0] ||
this._ignoreFitToMap[1] !== this.location[1])
) {
this.fitMap();
}
}
if (changedProps.has("radius")) {
this._updateRadius();
}
if (changedProps.has("radiusColor")) {
this._updateRadiusColor();
}
if (changedProps.has("icon")) {
this._updateIcon();
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.themes.darkMode === this.hass.themes.darkMode) {
return;
}
if (!this._leafletMap || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
this.Leaflet,
this._leafletMap,
this._tileLayer,
this.hass.themes.darkMode
);
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.querySelector("div")!;
}
private async _initMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this.darkMode ?? this.hass.themes.darkMode,
Boolean(this.radius)
);
this._leafletMap.addEventListener(
"click",
// @ts-ignore
(ev: LeafletMouseEvent) => this._locationUpdated(ev.latlng)
);
this._updateIcon();
this._updateMarker();
this.fitMap();
this._leafletMap.invalidateSize();
}
private _locationUpdated(latlng: LatLng) {
let longitude = latlng.lng;
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
longitude = (((longitude % 360.0) + 540.0) % 360.0) - 180.0;
}
this.location = this._ignoreFitToMap = [latlng.lat, longitude];
fireEvent(this, "change", undefined, { bubbles: false });
}
private _radiusUpdated() {
this._ignoreFitToMap = this.location;
this.radius = (this._locationMarker as Circle).getRadius();
fireEvent(this, "change", undefined, { bubbles: false });
}
private _updateIcon() {
if (!this.icon) {
this._iconEl = undefined;
return;
}
// create icon
let iconHTML = "";
const el = document.createElement("ha-icon");
el.setAttribute("icon", this.icon);
iconHTML = el.outerHTML;
this._iconEl = this.Leaflet!.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: "light leaflet-edit-move",
});
this._setIcon();
}
private _setIcon() {
if (!this._locationMarker || !this._iconEl) {
return;
}
if (!this.radius) {
(this._locationMarker as Marker).setIcon(this._iconEl);
return;
}
// @ts-ignore
const moveMarker = this._locationMarker.editing._moveMarker;
moveMarker.setIcon(this._iconEl);
}
private _setupEdit() {
// @ts-ignore
this._locationMarker.editing.enable();
// @ts-ignore
const moveMarker = this._locationMarker.editing._moveMarker;
// @ts-ignore
const resizeMarker = this._locationMarker.editing._resizeMarkers[0];
this._setIcon();
moveMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._locationUpdated(ev.target.getLatLng())
);
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._radiusUpdated(ev)
);
}
private async _updateMarker(): Promise<void> {
if (!this.location) {
if (this._locationMarker) {
this._locationMarker.remove();
this._locationMarker = undefined;
}
return;
}
if (this._locationMarker) {
this._locationMarker.setLatLng(this.location);
if (this.radius) {
// @ts-ignore
this._locationMarker.editing.disable();
await nextRender();
this._setupEdit();
}
return;
}
if (!this.radius) {
this._locationMarker = this.Leaflet!.marker(this.location, {
draggable: true,
});
this._setIcon();
this._locationMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._locationUpdated(ev.target.getLatLng())
);
this._leafletMap!.addLayer(this._locationMarker);
} else {
this._locationMarker = this.Leaflet!.circle(this.location, {
color: this.radiusColor || defaultRadiusColor,
radius: this.radius,
});
this._leafletMap!.addLayer(this._locationMarker);
this._setupEdit();
}
}
private _updateRadius(): void {
if (!this._locationMarker || !this.radius) {
return;
}
(this._locationMarker as Circle).setRadius(this.radius);
}
private _updateRadiusColor(): void {
if (!this._locationMarker || !this.radius) {
return;
}
(this._locationMarker as Circle).setStyle({ color: this.radiusColor });
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
height: 300px;
}
#map {
height: 100%;
background: inherit;
}
.leaflet-edit-move {
border-radius: 50%;
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-location-editor": LocationEditor;
}
}
+102 -134
View File
@@ -3,10 +3,8 @@ import {
DivIcon,
DragEndEvent,
LatLng,
Map,
Marker,
MarkerOptions,
TileLayer,
} from "leaflet";
import {
css,
@@ -16,15 +14,13 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import {
LeafletModuleType,
replaceTileLayer,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import { defaultRadiusColor } from "../../data/zone";
import { HomeAssistant } from "../../types";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HomeAssistant } from "../../types";
import "./ha-map";
import type { HaMap } from "./ha-map";
declare global {
// for fire event
@@ -51,38 +47,40 @@ export interface MarkerLocation {
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public locations?: MarkerLocation[];
@property({ attribute: false }) public locations?: MarkerLocation[];
public fitZoom = 16;
@property({ type: Boolean }) public autoFit = false;
@property({ type: Number }) public zoom = 16;
@property({ type: Boolean }) public darkMode?: boolean;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
// eslint-disable-next-line
private _leafletMap?: Map;
constructor() {
super();
private _tileLayer?: TileLayer;
private _locationMarkers?: { [key: string]: Marker | Circle };
private _circles: Record<string, Circle> = {};
import("leaflet").then((module) => {
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
this.updateComplete.then(() => this.fitMap());
});
});
}
public fitMap(): void {
if (
!this._leafletMap ||
!this._locationMarkers ||
!Object.keys(this._locationMarkers).length
) {
return;
}
const bounds = this.Leaflet!.latLngBounds(
Object.values(this._locationMarkers).map((item) => item.getLatLng())
);
this._leafletMap.fitBounds(bounds.pad(0.5));
this.map.fitMap();
}
public fitMarker(id: string): void {
if (!this._leafletMap || !this._locationMarkers) {
if (!this.map.leafletMap || !this._locationMarkers) {
return;
}
const marker = this._locationMarkers[id];
@@ -90,29 +88,44 @@ export class HaLocationsEditor extends LitElement {
return;
}
if ("getBounds" in marker) {
this._leafletMap.fitBounds(marker.getBounds());
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
} else {
const circle = this._circles[id];
if (circle) {
this._leafletMap.fitBounds(circle.getBounds());
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this._leafletMap.setView(marker.getLatLng(), this.fitZoom);
this.map.leafletMap.setView(marker.getLatLng(), this.zoom);
}
}
}
protected render(): TemplateResult {
return html` <div id="map"></div> `;
return html`<ha-map
.hass=${this.hass}
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.darkMode=${this.darkMode}
></ha-map>`;
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this._initMap();
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): Array<Marker | Circle> => {
const layers: Array<Marker | Circle> = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
}
return layers;
}
);
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
// Still loading.
if (!this.Leaflet) {
@@ -122,37 +135,6 @@ export class HaLocationsEditor extends LitElement {
if (changedProps.has("locations")) {
this._updateMarkers();
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.themes.darkMode === this.hass.themes.darkMode) {
return;
}
if (!this._leafletMap || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
this.Leaflet,
this._leafletMap,
this._tileLayer,
this.hass.themes.darkMode
);
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.querySelector("div")!;
}
private async _initMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this.hass.themes.darkMode,
true
);
this._updateMarkers();
this.fitMap();
this._leafletMap.invalidateSize();
}
private _updateLocation(ev: DragEndEvent) {
@@ -189,21 +171,18 @@ export class HaLocationsEditor extends LitElement {
}
private _updateMarkers(): void {
if (this._locationMarkers) {
Object.values(this._locationMarkers).forEach((marker) => {
marker.remove();
});
this._locationMarkers = undefined;
Object.values(this._circles).forEach((circle) => circle.remove());
this._circles = {};
}
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
this._locationMarkers = {};
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor = getComputedStyle(this).getPropertyValue(
"--accent-color"
);
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
@@ -228,45 +207,46 @@ export class HaLocationsEditor extends LitElement {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultRadiusColor,
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
circle.addTo(this._leafletMap!);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
this._locationMarkers![location.id] = circle;
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
this._circles[location.id] = circle;
circles[location.id] = circle;
}
}
if (
@@ -275,6 +255,7 @@ export class HaLocationsEditor extends LitElement {
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
@@ -293,13 +274,14 @@ export class HaLocationsEditor extends LitElement {
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
)
.addTo(this._leafletMap!);
);
(marker as any).id = location.id;
this._locationMarkers![location.id] = marker;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
}
static get styles(): CSSResultGroup {
@@ -308,23 +290,9 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 300px;
}
#map {
ha-map {
height: 100%;
}
.leaflet-marker-draggable {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
.named-icon {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
`;
}
}
+302 -162
View File
@@ -1,13 +1,15 @@
import { Circle, Layer, Map, Marker, TileLayer } from "leaflet";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
Circle,
CircleMarker,
LatLngTuple,
Layer,
Map,
Marker,
Polyline,
TileLayer,
} from "leaflet";
import { css, CSSResultGroup, PropertyValues, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
LeafletModuleType,
replaceTileLayer,
@@ -15,194 +17,324 @@ import {
} from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { debounce } from "../../common/util/debounce";
import "../../panels/map/ha-entity-marker";
import "./ha-entity-marker";
import { HomeAssistant } from "../../types";
import "../ha-icon-button";
import { installResizeObserver } from "../../panels/lovelace/common/install-resize-observer";
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
export interface HaMapPaths {
points: LatLngTuple[];
color?: string;
gradualOpacity?: number;
}
export interface HaMapEntity {
entity_id: string;
color: string;
}
@customElement("ha-map")
class HaMap extends LitElement {
export class HaMap extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entities?: string[];
@property({ attribute: false }) public entities?: string[] | HaMapEntity[];
@property() public darkMode?: boolean;
@property({ attribute: false }) public paths?: HaMapPaths[];
@property() public zoom?: number;
@property({ attribute: false }) public layers?: Layer[];
@property({ type: Boolean }) public autoFit = false;
@property({ type: Boolean }) public fitZones?: boolean;
@property({ type: Boolean }) public darkMode?: boolean;
@property({ type: Number }) public zoom = 14;
@state() private _loaded = false;
public leafletMap?: Map;
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _tileLayer?: TileLayer;
// @ts-ignore
private _resizeObserver?: ResizeObserver;
private _debouncedResizeListener = debounce(
() => {
if (!this._leafletMap) {
return;
}
this._leafletMap.invalidateSize();
},
100,
false
);
private _mapItems: Array<Marker | Circle> = [];
private _mapZones: Array<Marker | Circle> = [];
private _connected = false;
private _mapPaths: Array<Polyline | CircleMarker> = [];
public connectedCallback(): void {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated) {
this.loadMap();
this._attachObserver();
}
this._loadMap();
this._attachObserver();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._connected = false;
if (this._leafletMap) {
this._leafletMap.remove();
this._leafletMap = undefined;
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
}
this._loaded = false;
if (this._resizeObserver) {
this._resizeObserver.unobserve(this._mapEl);
} else {
window.removeEventListener("resize", this._debouncedResizeListener);
this._resizeObserver.unobserve(this);
}
}
protected render(): TemplateResult {
if (!this.entities) {
return html``;
}
return html` <div id="map"></div> `;
}
protected update(changedProps: PropertyValues) {
super.update(changedProps);
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this.loadMap();
if (this._connected) {
this._attachObserver();
}
}
protected shouldUpdate(changedProps) {
if (!changedProps.has("hass") || changedProps.size > 1) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this.entities) {
return true;
}
// Check if any state has changed
for (const entity of this.entities) {
if (oldHass.states[entity] !== this.hass!.states[entity]) {
return true;
}
}
return false;
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("hass")) {
this._drawEntities();
this._fitMap();
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.themes.darkMode === this.hass.themes.darkMode) {
return;
}
if (!this.Leaflet || !this._leafletMap || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
this.Leaflet,
this._leafletMap,
this._tileLayer,
this.hass.themes.darkMode
);
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.getElementById("map") as HTMLDivElement;
}
private async loadMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this.darkMode ?? this.hass.themes.darkMode
);
this._drawEntities();
this._leafletMap.invalidateSize();
this._fitMap();
}
private _fitMap(): void {
if (!this._leafletMap || !this.Leaflet || !this.hass) {
if (!this._loaded) {
return;
}
if (this._mapItems.length === 0) {
this._leafletMap.setView(
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (changedProps.has("_loaded") || changedProps.has("entities")) {
this._drawEntities();
} else if (this._loaded && oldHass && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
if (
oldHass.states[getEntityId(entity)] !==
this.hass!.states[getEntityId(entity)]
) {
this._drawEntities();
break;
}
}
}
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
}
if (changedProps.has("_loaded") || changedProps.has("layers")) {
this._drawLayers(changedProps.get("layers") as Layer[] | undefined);
}
if (
changedProps.has("_loaded") ||
((changedProps.has("entities") || changedProps.has("layers")) &&
this.autoFit)
) {
this.fitMap();
}
if (changedProps.has("zoom")) {
this.leafletMap!.setZoom(this.zoom);
}
if (
!changedProps.has("darkMode") &&
(!changedProps.has("hass") ||
(oldHass && oldHass.themes.darkMode === this.hass.themes.darkMode))
) {
return;
}
const darkMode = this.darkMode ?? this.hass.themes.darkMode;
this._tileLayer = replaceTileLayer(
this.Leaflet!,
this.leafletMap!,
this._tileLayer!,
darkMode
);
this.shadowRoot!.getElementById("map")!.classList.toggle("dark", darkMode);
}
private async _loadMap(): Promise<void> {
let map = this.shadowRoot!.getElementById("map");
if (!map) {
map = document.createElement("div");
map.id = "map";
this.shadowRoot!.append(map);
}
const darkMode = this.darkMode ?? this.hass.themes.darkMode;
[this.leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
map,
darkMode
);
this.shadowRoot!.getElementById("map")!.classList.toggle("dark", darkMode);
this._loaded = true;
}
public fitMap(): void {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
if (!this._mapItems.length && !this.layers?.length) {
this.leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
this.zoom || 14
this.zoom
);
return;
}
const bounds = this.Leaflet.latLngBounds(
let bounds = this.Leaflet.latLngBounds(
this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : []
);
this._leafletMap.fitBounds(bounds.pad(0.5));
if (this.zoom && this._leafletMap.getZoom() > this.zoom) {
this._leafletMap.setZoom(this.zoom);
if (this.fitZones) {
this._mapZones?.forEach((zone) => {
bounds.extend(
"getBounds" in zone ? zone.getBounds() : zone.getLatLng()
);
});
}
this.layers?.forEach((layer: any) => {
bounds.extend(
"getBounds" in layer ? layer.getBounds() : layer.getLatLng()
);
});
if (!this.layers) {
bounds = bounds.pad(0.5);
}
this.leafletMap.fitBounds(bounds, { maxZoom: this.zoom });
}
private _drawLayers(prevLayers: Layer[] | undefined): void {
if (prevLayers) {
prevLayers.forEach((layer) => layer.remove());
}
if (!this.layers) {
return;
}
const map = this.leafletMap!;
this.layers.forEach((layer) => {
map.addLayer(layer);
});
}
private _drawPaths(): void {
const hass = this.hass;
const map = this.leafletMap;
const Leaflet = this.Leaflet;
if (!hass || !map || !Leaflet) {
return;
}
if (this._mapPaths.length) {
this._mapPaths.forEach((marker) => marker.remove());
this._mapPaths = [];
}
if (!this.paths) {
return;
}
const darkPrimaryColor = getComputedStyle(this).getPropertyValue(
"--dark-primary-color"
);
this.paths.forEach((path) => {
let opacityStep: number;
let baseOpacity: number;
if (path.gradualOpacity) {
opacityStep = path.gradualOpacity / (path.points.length - 2);
baseOpacity = 1 - path.gradualOpacity;
}
for (
let pointIndex = 0;
pointIndex < path.points.length - 1;
pointIndex++
) {
const opacity = path.gradualOpacity
? baseOpacity! + pointIndex * opacityStep!
: undefined;
// DRAW point
this._mapPaths.push(
Leaflet!.circleMarker(path.points[pointIndex], {
radius: 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: false,
})
);
// DRAW line between this and next point
this._mapPaths.push(
Leaflet!.polyline(
[path.points[pointIndex], path.points[pointIndex + 1]],
{
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
}
)
);
}
const pointIndex = path.points.length - 1;
if (pointIndex >= 0) {
const opacity = path.gradualOpacity
? baseOpacity! + pointIndex * opacityStep!
: undefined;
// DRAW end path point
this._mapPaths.push(
Leaflet!.circleMarker(path.points[pointIndex], {
radius: 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: false,
})
);
}
this._mapPaths.forEach((marker) => map.addLayer(marker));
});
}
private _drawEntities(): void {
const hass = this.hass;
const map = this._leafletMap;
const map = this.leafletMap;
const Leaflet = this.Leaflet;
if (!hass || !map || !Leaflet) {
return;
}
if (this._mapItems) {
if (this._mapItems.length) {
this._mapItems.forEach((marker) => marker.remove());
this._mapItems = [];
}
const mapItems: Layer[] = (this._mapItems = []);
if (this._mapZones) {
if (this._mapZones.length) {
this._mapZones.forEach((marker) => marker.remove());
this._mapZones = [];
}
const mapZones: Layer[] = (this._mapZones = []);
const allEntities = this.entities!.concat();
if (!this.entities) {
return;
}
for (const entity of allEntities) {
const entityId = entity;
const stateObj = hass.states[entityId];
const computedStyles = getComputedStyle(this);
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
const className =
this.darkMode ?? this.hass.themes.darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = hass.states[getEntityId(entity)];
if (!stateObj) {
continue;
}
@@ -240,13 +372,12 @@ class HaMap extends LitElement {
}
// create marker with the icon
mapZones.push(
this._mapZones.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className:
this.darkMode ?? this.hass.themes.darkMode ? "dark" : "light",
className,
}),
interactive: false,
title,
@@ -254,10 +385,10 @@ class HaMap extends LitElement {
);
// create circle around it
mapZones.push(
this._mapZones.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#FF9800",
color: zoneColor,
radius,
})
);
@@ -273,17 +404,20 @@ class HaMap extends LitElement {
.join("")
.substr(0, 3);
// create market with the icon
mapItems.push(
// create marker with the icon
this._mapItems.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
// Leaflet clones this element before adding it to the map. This messes up
// our Polymer object and we can't pass data through. Thus we hack like this.
html: `
<ha-entity-marker
entity-id="${entityId}"
entity-id="${getEntityId(entity)}"
entity-name="${entityName}"
entity-picture="${entityPicture || ""}"
${
typeof entity !== "string"
? `entity-color="${entity.color}"`
: ""
}
></ha-entity-marker>
`,
iconSize: [48, 48],
@@ -295,10 +429,10 @@ class HaMap extends LitElement {
// create circle around if entity has accuracy
if (gpsAccuracy) {
mapItems.push(
this._mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#0288D1",
color: darkPrimaryColor,
radius: gpsAccuracy,
})
);
@@ -309,20 +443,14 @@ class HaMap extends LitElement {
this._mapZones.forEach((marker) => map.addLayer(marker));
}
private _attachObserver(): void {
// Observe changes to map size and invalidate to prevent broken rendering
// Uses ResizeObserver in Chrome, otherwise window resize event
// @ts-ignore
if (typeof ResizeObserver === "function") {
// @ts-ignore
this._resizeObserver = new ResizeObserver(() =>
this._debouncedResizeListener()
);
this._resizeObserver.observe(this._mapEl);
} else {
window.addEventListener("resize", this._debouncedResizeListener);
private async _attachObserver(): Promise<void> {
if (!this._resizeObserver) {
await installResizeObserver();
this._resizeObserver = new ResizeObserver(() => {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
});
}
this._resizeObserver.observe(this);
}
static get styles(): CSSResultGroup {
@@ -337,13 +465,25 @@ class HaMap extends LitElement {
#map.dark {
background: #090909;
}
.light {
color: #000000;
}
.dark {
color: #ffffff;
}
.light {
color: #000000;
.leaflet-marker-draggable {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: 50%;
cursor: nesw-resize !important;
}
.named-icon {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
`;
}
-433
View File
@@ -1,433 +0,0 @@
import "@polymer/polymer/lib/utils/debounce";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time";
import LocalizeMixin from "../mixins/localize-mixin";
import "./entity/ha-chart-base";
class StateHistoryChartLine extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style>
:host {
display: block;
overflow: hidden;
height: 0;
transition: height 0.3s ease-in-out;
}
</style>
<ha-chart-base
id="chart"
hass="[[hass]]"
data="[[chartData]]"
identifier="[[identifier]]"
rendered="{{rendered}}"
></ha-chart-base>
`;
}
static get properties() {
return {
hass: {
type: Object,
},
chartData: Object,
data: Object,
names: Object,
unit: String,
identifier: String,
isSingleDevice: {
type: Boolean,
value: false,
},
endTime: Object,
rendered: {
type: Boolean,
value: false,
observer: "_onRenderedChanged",
},
};
}
static get observers() {
return ["dataChanged(data, endTime, isSingleDevice)"];
}
connectedCallback() {
super.connectedCallback();
this._isAttached = true;
this.drawChart();
}
ready() {
super.ready();
// safari doesn't always render the canvas when we animate it, so we remove overflow hidden when the animation is complete
this.addEventListener("transitionend", () => {
this.style.overflow = "auto";
});
}
dataChanged() {
this.drawChart();
}
_onRenderedChanged(rendered) {
if (rendered) {
this.animateHeight();
}
}
animateHeight() {
requestAnimationFrame(() =>
requestAnimationFrame(() => {
this.style.height = this.$.chart.scrollHeight + "px";
})
);
}
drawChart() {
if (!this._isAttached) {
return;
}
const unit = this.unit;
const deviceStates = this.data;
const datasets = [];
let endTime;
if (deviceStates.length === 0) {
return;
}
function safeParseFloat(value) {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
}
endTime =
this.endTime ||
// Get the highest date from the last date of each device
new Date(
Math.max.apply(
null,
deviceStates.map(
(devSts) =>
new Date(devSts.states[devSts.states.length - 1].last_changed)
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const names = this.names || {};
deviceStates.forEach((states) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
// array containing [value1, value2, etc]
let prevValues;
const data = [];
function pushData(timestamp, datavalues) {
if (!datavalues) return;
if (timestamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
data.forEach((d, i) => {
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
// null data values show up as gaps in the chart.
// If the current value for the dataset is null and the previous
// value of the data set is not null, then add an 'end' point
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data.push({ x: timestamp, y: prevValues[i] });
}
d.data.push({ x: timestamp, y: datavalues[i] });
});
prevValues = datavalues;
}
function addColumn(nameY, step, fill) {
let dataFill = false;
let dataStep = false;
if (fill) {
dataFill = "origin";
}
if (step) {
dataStep = "before";
}
data.push({
label: nameY,
fill: dataFill,
steppedLine: dataStep,
pointRadius: 0,
data: [],
unitText: unit,
});
}
if (
domain === "thermostat" ||
domain === "climate" ||
domain === "water_heater"
) {
const hasHvacAction = states.states.some(
(state) => state.attributes && state.attributes.hvac_action
);
const isHeating =
domain === "climate" && hasHvacAction
? (state) => state.attributes.hvac_action === "heating"
: (state) => state.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (state) => state.attributes.hvac_action === "cooling"
: (state) => state.state === "cool";
const hasHeat = states.states.some(isHeating);
const hasCool = states.states.some(isCooling);
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(
(state) =>
state.attributes &&
state.attributes.target_temp_high !==
state.attributes.target_temp_low
);
addColumn(
`${this.hass.localize(
"ui.card.climate.current_temperature",
"name",
name
)}`,
true
);
if (hasHeat) {
addColumn(
`${this.hass.localize("ui.card.climate.heating", "name", name)}`,
true,
true
);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasCool) {
addColumn(
`${this.hass.localize("ui.card.climate.cooling", "name", name)}`,
true,
true
);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasTargetRange) {
addColumn(
`${this.hass.localize(
"ui.card.climate.target_temperature_mode",
"name",
name,
"mode",
this.hass.localize("ui.card.climate.high")
)}`,
true
);
addColumn(
`${this.hass.localize(
"ui.card.climate.target_temperature_mode",
"name",
name,
"mode",
this.hass.localize("ui.card.climate.low")
)}`,
true
);
} else {
addColumn(
`${this.hass.localize(
"ui.card.climate.target_temperature_entity",
"name",
name
)}`,
true
);
}
states.states.forEach((state) => {
if (!state.attributes) return;
const curTemp = safeParseFloat(state.attributes.current_temperature);
const series = [curTemp];
if (hasHeat) {
series.push(isHeating(state) ? curTemp : null);
}
if (hasCool) {
series.push(isCooling(state) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
state.attributes.target_temp_high
);
const targetLow = safeParseFloat(state.attributes.target_temp_low);
series.push(targetHigh, targetLow);
pushData(new Date(state.last_changed), series);
} else {
const target = safeParseFloat(state.attributes.temperature);
series.push(target);
pushData(new Date(state.last_changed), series);
}
});
} else if (domain === "humidifier") {
addColumn(
`${this.hass.localize(
"ui.card.humidifier.target_humidity_entity",
"name",
name
)}`,
true
);
addColumn(
`${this.hass.localize("ui.card.humidifier.on_entity", "name", name)}`,
true,
true
);
states.states.forEach((state) => {
if (!state.attributes) return;
const target = safeParseFloat(state.attributes.humidity);
const series = [target];
series.push(state.state === "on" ? target : null);
pushData(new Date(state.last_changed), series);
});
} else {
// Only disable interpolation for sensors
const isStep = domain === "sensor";
addColumn(name, isStep);
let lastValue = null;
let lastDate = null;
let lastNullDate = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
states.states.forEach((state) => {
const value = safeParseFloat(state.state);
const date = new Date(state.last_changed);
if (value !== null && lastNullDate !== null) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (
value === null &&
lastNullDate === null &&
lastValue !== null
) {
lastNullDate = date;
}
});
}
// Add an entry for final values
pushData(endTime, prevValues, false);
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
const formatTooltipTitle = (items, data) => {
const item = items[0];
const date = data.datasets[item.datasetIndex].data[item.index].x;
return formatDateTimeWithSeconds(date, this.hass.locale);
};
const chartOptions = {
type: "line",
unit: unit,
legend: !this.isSingleDevice,
options: {
scales: {
xAxes: [
{
type: "time",
ticks: {
major: {
fontStyle: "bold",
},
source: "auto",
sampleSize: 5,
autoSkipPadding: 20,
maxRotation: 0,
},
},
],
yAxes: [
{
ticks: {
maxTicksLimit: 7,
},
},
],
},
tooltips: {
mode: "neareach",
callbacks: {
title: formatTooltipTitle,
},
},
hover: {
mode: "neareach",
},
layout: {
padding: {
top: 5,
},
},
elements: {
line: {
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5,
},
point: {
hitRadius: 5,
},
},
plugins: {
filler: {
propagate: true,
},
},
},
data: {
labels: [],
datasets: datasets,
},
};
this.chartData = chartOptions;
}
}
customElements.define("state-history-chart-line", StateHistoryChartLine);
@@ -1,286 +0,0 @@
import "@polymer/polymer/lib/utils/debounce";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time";
import { computeDomain } from "../common/entity/compute_domain";
import { computeRTL } from "../common/util/compute_rtl";
import LocalizeMixin from "../mixins/localize-mixin";
import "./entity/ha-chart-base";
/** Binary sensor device classes for which the static colors for on/off need to be inverted.
* List the ones were "off" = good or normal state = should be rendered "green".
*/
const BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED = new Set([
"battery",
"door",
"garage_door",
"gas",
"lock",
"opening",
"problem",
"safety",
"smoke",
"window",
]);
class StateHistoryChartTimeline extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style>
:host {
display: block;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
:host([rendered]) {
opacity: 1;
}
ha-chart-base {
direction: ltr;
}
</style>
<ha-chart-base
hass="[[hass]]"
data="[[chartData]]"
rendered="{{rendered}}"
rtl="{{rtl}}"
></ha-chart-base>
`;
}
static get properties() {
return {
hass: {
type: Object,
},
chartData: Object,
data: {
type: Object,
observer: "dataChanged",
},
names: Object,
noSingle: Boolean,
endTime: Date,
rendered: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
rtl: {
reflectToAttribute: true,
computed: "_computeRTL(hass)",
},
};
}
static get observers() {
return ["dataChanged(data, endTime, localize, language)"];
}
connectedCallback() {
super.connectedCallback();
this._isAttached = true;
this.drawChart();
}
dataChanged() {
this.drawChart();
}
drawChart() {
const staticColors = {
on: 1,
off: 0,
home: 1,
not_home: 0,
unavailable: "#a0a0a0",
unknown: "#606060",
idle: 2,
};
let stateHistory = this.data;
if (!this._isAttached) {
return;
}
if (!stateHistory) {
stateHistory = [];
}
const startTime = new Date(
stateHistory.reduce(
(minTime, stateInfo) =>
Math.min(minTime, new Date(stateInfo.data[0].last_changed)),
new Date()
)
);
// end time is Math.max(startTime, last_event)
let endTime =
this.endTime ||
new Date(
stateHistory.reduce(
(maxTime, stateInfo) =>
Math.max(
maxTime,
new Date(stateInfo.data[stateInfo.data.length - 1].last_changed)
),
startTime
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const labels = [];
const datasets = [];
// stateHistory is a list of lists of sorted state objects
const names = this.names || {};
stateHistory.forEach((stateInfo) => {
let newLastChanged;
let prevState = null;
let locState = null;
let prevLastChanged = startTime;
const entityDisplay = names[stateInfo.entity_id] || stateInfo.name;
const invertOnOff =
computeDomain(stateInfo.entity_id) === "binary_sensor" &&
BINARY_SENSOR_DEVICE_CLASS_COLOR_INVERTED.has(
this.hass.states[stateInfo.entity_id].attributes.device_class
);
const dataRow = [];
stateInfo.data.forEach((state) => {
let newState = state.state;
const timeStamp = new Date(state.last_changed);
if (newState === undefined || newState === "") {
newState = null;
}
if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState !== null && newState !== prevState) {
newLastChanged = new Date(state.last_changed);
dataRow.push([
prevLastChanged,
newLastChanged,
locState,
prevState,
invertOnOff,
]);
prevState = newState;
locState = state.state_localize;
prevLastChanged = newLastChanged;
} else if (prevState === null) {
prevState = newState;
locState = state.state_localize;
prevLastChanged = new Date(state.last_changed);
}
});
if (prevState !== null) {
dataRow.push([
prevLastChanged,
endTime,
locState,
prevState,
invertOnOff,
]);
}
datasets.push({
data: dataRow,
entity_id: stateInfo.entity_id,
});
labels.push(entityDisplay);
});
const formatTooltipLabel = (item, data) => {
const values = data.datasets[item.datasetIndex].data[item.index];
const start = formatDateTimeWithSeconds(values[0], this.hass.locale);
const end = formatDateTimeWithSeconds(values[1], this.hass.locale);
const state = values[2];
return [state, start, end];
};
const formatTooltipBeforeBody = (item, data) => {
if (!this.hass.userData || !this.hass.userData.showAdvanced || !item[0]) {
return "";
}
// Extract the entity ID from the dataset.
const values = data.datasets[item[0].datasetIndex];
return values.entity_id || "";
};
const chartOptions = {
type: "timeline",
options: {
tooltips: {
callbacks: {
label: formatTooltipLabel,
beforeBody: formatTooltipBeforeBody,
},
},
scales: {
xAxes: [
{
ticks: {
major: {
fontStyle: "bold",
},
sampleSize: 5,
autoSkipPadding: 50,
maxRotation: 0,
},
categoryPercentage: undefined,
barPercentage: undefined,
time: {
format: undefined,
},
},
],
yAxes: [
{
afterSetDimensions: (yaxe) => {
yaxe.maxWidth = yaxe.chart.width * 0.18;
},
position: this._computeRTL ? "right" : "left",
categoryPercentage: undefined,
barPercentage: undefined,
time: { format: undefined },
},
],
},
},
datasets: {
categoryPercentage: 0.8,
barPercentage: 0.9,
},
data: {
labels: labels,
datasets: datasets,
},
colors: {
staticColors: staticColors,
staticColorIndex: 3,
},
};
this.chartData = chartOptions;
}
_computeRTL(hass) {
return computeRTL(hass);
}
}
customElements.define(
"state-history-chart-timeline",
StateHistoryChartTimeline
);
+6
View File
@@ -27,6 +27,12 @@ export type ConfigEntryMutableParams = Partial<
>
>;
export const ERROR_STATES: ConfigEntry["state"][] = [
"migration_error",
"setup_error",
"setup_retry",
];
export const getConfigEntries = (hass: HomeAssistant) =>
hass.callApi<ConfigEntry[]>("GET", "config/config_entries/entry");
+1
View File
@@ -41,6 +41,7 @@ export interface HassioSnapshotDetail extends HassioSnapshot {
export interface HassioFullSnapshotCreateParams {
name: string;
password?: string;
confirm_password?: string;
}
export interface HassioPartialSnapshotCreateParams
extends HassioFullSnapshotCreateParams {
+1
View File
@@ -8,6 +8,7 @@ export enum LightColorModes {
ONOFF = "onoff",
BRIGHTNESS = "brightness",
COLOR_TEMP = "color_temp",
WHITE = "white",
HS = "hs",
XY = "xy",
RGB = "rgb",
+25
View File
@@ -0,0 +1,25 @@
import {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { HomeAssistant } from "../types";
interface SelectEntityAttributes extends HassEntityAttributeBase {
options: string[];
}
export interface SelectEntity extends HassEntityBase {
attributes: SelectEntityAttributes;
}
export const setSelectOption = (
hass: HomeAssistant,
entity: string,
option: string
) =>
hass.callService(
"select",
"select_option",
{ option },
{ entity_id: entity }
);
-8
View File
@@ -1,14 +1,6 @@
import { navigate } from "../common/navigate";
import {
DEFAULT_ACCENT_COLOR,
DEFAULT_PRIMARY_COLOR,
} from "../resources/ha-style";
import { HomeAssistant } from "../types";
export const defaultRadiusColor = DEFAULT_ACCENT_COLOR;
export const homeRadiusColor = DEFAULT_PRIMARY_COLOR;
export const passiveRadiusColor = "#9b9b9b";
export interface Zone {
id: string;
name: string;
+52 -3
View File
@@ -21,6 +21,7 @@ export interface ZWaveJSClient {
export interface ZWaveJSController {
home_id: string;
nodes: number[];
is_heal_network_active: boolean;
}
export interface ZWaveJSNode {
@@ -77,6 +78,11 @@ export interface ZWaveJSRefreshNodeStatusMessage {
stage?: string;
}
export interface ZWaveJSHealNetworkStatusMessage {
event: string;
heal_node_status: { [key: number]: string };
}
export enum NodeStatus {
Unknown,
Asleep,
@@ -172,6 +178,37 @@ export const reinterviewNode = (
}
);
export const healNetwork = (
hass: HomeAssistant,
entry_id: string
): Promise<UnsubscribeFunc> =>
hass.callWS({
type: "zwave_js/begin_healing_network",
entry_id: entry_id,
});
export const stopHealNetwork = (
hass: HomeAssistant,
entry_id: string
): Promise<UnsubscribeFunc> =>
hass.callWS({
type: "zwave_js/stop_healing_network",
entry_id: entry_id,
});
export const subscribeHealNetworkProgress = (
hass: HomeAssistant,
entry_id: string,
callbackFunction: (message: ZWaveJSHealNetworkStatusMessage) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/subscribe_heal_network_progress",
entry_id: entry_id,
}
);
export const getIdentifiersFromDevice = (
device: DeviceRegistryEntry
): ZWaveJSNodeIdentifiers | undefined => {
@@ -193,6 +230,18 @@ export const getIdentifiersFromDevice = (
};
};
export type ZWaveJSLogUpdate = ZWaveJSLogMessageUpdate | ZWaveJSLogConfigUpdate;
interface ZWaveJSLogMessageUpdate {
type: "log_message";
log_message: ZWaveJSLogMessage;
}
interface ZWaveJSLogConfigUpdate {
type: "log_config";
log_config: ZWaveJSLogConfig;
}
export interface ZWaveJSLogMessage {
timestamp: string;
level: string;
@@ -203,10 +252,10 @@ export interface ZWaveJSLogMessage {
export const subscribeZWaveJSLogs = (
hass: HomeAssistant,
entry_id: string,
callback: (message: ZWaveJSLogMessage) => void
callback: (update: ZWaveJSLogUpdate) => void
) =>
hass.connection.subscribeMessage<ZWaveJSLogMessage>(callback, {
type: "zwave_js/subscribe_logs",
hass.connection.subscribeMessage<ZWaveJSLogUpdate>(callback, {
type: "zwave_js/subscribe_log_updates",
entry_id,
});
@@ -232,7 +232,6 @@ class DataEntryFlowDialog extends LitElement {
<step-flow-pick-handler
.hass=${this.hass}
.handlers=${this._handlers}
.showAdvanced=${this._params.showAdvanced}
@handler-picked=${this._handlerPicked}
></step-flow-pick-handler>
`
@@ -90,7 +90,10 @@ export const showConfigFlowDialog = (
},
renderShowFormStepFieldError(hass, step, error) {
return hass.localize(`component.${step.handler}.config.error.${error}`);
return hass.localize(
`component.${step.handler}.config.error.${error}`,
step.description_placeholders
);
},
renderExternalStepHeader(hass, step) {
@@ -88,9 +88,10 @@ export const showOptionsFlowDialog = (
);
},
renderShowFormStepFieldError(hass, _step, error) {
renderShowFormStepFieldError(hass, step, error) {
return hass.localize(
`component.${configEntry.domain}.options.error.${error}`
`component.${configEntry.domain}.options.error.${error}`,
step.description_placeholders
);
},
@@ -108,12 +109,28 @@ export const showOptionsFlowDialog = (
`;
},
renderShowFormProgressHeader(_hass, _step) {
return "";
renderShowFormProgressHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.options.step.${step.step_id}.title`
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormProgressDescription(_hass, _step) {
return "";
renderShowFormProgressDescription(hass, step) {
const description = hass.localize(
`component.${configEntry.domain}.options.progress.${step.progress_action}`,
step.description_placeholders
);
return description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: "";
},
}
);
@@ -3,7 +3,6 @@ import "@polymer/paper-item/paper-item-body";
import Fuse from "fuse.js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -34,9 +33,7 @@ declare global {
class StepFlowPickHandler extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public handlers!: string[];
@property() public showAdvanced?: boolean;
@property({ attribute: false }) public handlers!: string[];
@state() private _filter?: string;
@@ -87,47 +84,50 @@ class StepFlowPickHandler extends LitElement {
width: `${this._width}px`,
height: `${this._height}px`,
})}
class=${classMap({ advanced: Boolean(this.showAdvanced) })}
>
${handlers.map(
(handler: HandlerObj) =>
html`
<paper-icon-item
@click=${this._handlerPicked}
.handler=${handler}
>
<img
slot="item-icon"
loading="lazy"
src=${brandsUrl(handler.slug, "icon", true)}
referrerpolicy="no-referrer"
/>
${handlers.length
? handlers.map(
(handler: HandlerObj) =>
html`
<paper-icon-item
@click=${this._handlerPicked}
.handler=${handler}
>
<img
slot="item-icon"
loading="lazy"
src=${brandsUrl(handler.slug, "icon", true)}
referrerpolicy="no-referrer"
/>
<paper-item-body> ${handler.name} </paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-icon-item>
`
)}
<paper-item-body> ${handler.name} </paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-icon-item>
`
)
: html`
<p>
${this.hass.localize(
"ui.panel.config.integrations.note_about_integrations"
)}<br />
${this.hass.localize(
"ui.panel.config.integrations.note_about_website_reference"
)}<a
href="${documentationUrl(
this.hass,
`/integrations/${
this._filter ? `#search/${this._filter}` : ""
}`
)}"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.integrations.home_assistant_website"
)}</a
>.
</p>
`}
</div>
${this.showAdvanced
? html`
<p>
${this.hass.localize(
"ui.panel.config.integrations.note_about_integrations"
)}<br />
${this.hass.localize(
"ui.panel.config.integrations.note_about_website_reference"
)}<a
href="${documentationUrl(this.hass, "/integrations/")}"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.integrations.home_assistant_website"
)}</a
>.
</p>
`
: ""}
`;
}
@@ -193,9 +193,6 @@ class StepFlowPickHandler extends LitElement {
div {
max-height: calc(100vh - 134px);
}
div.advanced {
max-height: calc(100vh - 250px);
}
}
paper-icon-item {
cursor: pointer;
@@ -9,6 +9,7 @@ import {
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import "../../../components/ha-button-toggle-group";
@@ -28,11 +29,6 @@ import {
} from "../../../data/light";
import type { HomeAssistant } from "../../../types";
const toggleButtons = [
{ label: "Color", value: "color" },
{ label: "Temperature", value: LightColorModes.COLOR_TEMP },
];
@customElement("more-info-light")
class MoreInfoLight extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -59,7 +55,7 @@ class MoreInfoLight extends LitElement {
@state() private _colorPickerColor?: [number, number, number];
@state() private _mode?: "color" | LightColorModes.COLOR_TEMP;
@state() private _mode?: "color" | LightColorModes;
protected render(): TemplateResult {
if (!this.hass || !this.stateObj) {
@@ -71,6 +67,11 @@ class MoreInfoLight extends LitElement {
LightColorModes.COLOR_TEMP
);
const supportsWhite = lightSupportsColorMode(
this.stateObj,
LightColorModes.WHITE
);
const supportsRgbww = lightSupportsColorMode(
this.stateObj,
LightColorModes.RGBWW
@@ -101,16 +102,17 @@ class MoreInfoLight extends LitElement {
${this.stateObj.state === "on"
? html`
${supportsTemp || supportsColor ? html`<hr />` : ""}
${supportsTemp && supportsColor
${supportsColor && (supportsTemp || supportsWhite)
? html`<ha-button-toggle-group
fullWidth
.buttons=${toggleButtons}
.buttons=${this._toggleButtons(supportsTemp, supportsWhite)}
.active=${this._mode}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>`
: ""}
${supportsTemp &&
(!supportsColor || this._mode === LightColorModes.COLOR_TEMP)
((!supportsColor && !supportsWhite) ||
this._mode === LightColorModes.COLOR_TEMP)
? html`
<ha-labeled-slider
class="color_temp"
@@ -126,7 +128,8 @@ class MoreInfoLight extends LitElement {
></ha-labeled-slider>
`
: ""}
${supportsColor && (!supportsTemp || this._mode === "color")
${supportsColor &&
((!supportsTemp && !supportsWhite) || this._mode === "color")
? html`
<div class="segmentationContainer">
<ha-color-picker
@@ -251,7 +254,7 @@ class MoreInfoLight extends LitElement {
) {
this._mode = lightIsInColorMode(this.stateObj!)
? "color"
: LightColorModes.COLOR_TEMP;
: this.stateObj!.attributes.color_mode;
}
let brightnessAdjust = 100;
@@ -300,6 +303,19 @@ class MoreInfoLight extends LitElement {
}
}
private _toggleButtons = memoizeOne(
(supportsTemp: boolean, supportsWhite: boolean) => {
const modes = [{ label: "Color", value: "color" }];
if (supportsTemp) {
modes.push({ label: "Temperature", value: LightColorModes.COLOR_TEMP });
}
if (supportsWhite) {
modes.push({ label: "White", value: LightColorModes.WHITE });
}
return modes;
}
);
private _modeChanged(ev: CustomEvent) {
this._mode = ev.detail.value;
}
@@ -326,6 +342,14 @@ class MoreInfoLight extends LitElement {
this._brightnessSliderValue = bri;
if (this._mode === LightColorModes.WHITE) {
this.hass.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
white: Math.min(255, Math.round((bri * 255) / 100)),
});
return;
}
if (this._brightnessAdjusted) {
const rgb =
this.stateObj!.attributes.rgb_color ||
@@ -28,6 +28,7 @@ class MoreInfoPerson extends LitElement {
<ha-map
.hass=${this.hass}
.entities=${this._entityArray(this.stateObj.entity_id)}
autoFit
></ha-map>
`
: ""}
@@ -2,7 +2,7 @@ import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { throttle } from "../../common/util/throttle";
import "../../components/state-history-charts";
import "../../components/chart/state-history-charts";
import { getRecentWithCache } from "../../data/cached-history";
import { HistoryResult } from "../../data/history";
import { HomeAssistant } from "../../types";
+54 -17
View File
@@ -4,7 +4,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { throttle } from "../../common/util/throttle";
import "../../components/ha-circular-progress";
import "../../components/state-history-charts";
import { fetchUsers } from "../../data/user";
import { getLogbookData, LogbookEntry } from "../../data/logbook";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import "../../panels/logbook/ha-logbook";
@@ -22,10 +22,14 @@ export class MoreInfoLogbook extends LitElement {
@state() private _traceContexts?: TraceContexts;
@state() private _persons = {};
@state() private _userIdToName = {};
private _lastLogbookDate?: Date;
private _fetchUserPromise?: Promise<void>;
private _error?: string;
private _throttleGetLogbookEntries = throttle(() => {
this._getLogBookData();
}, 10000);
@@ -42,7 +46,13 @@ export class MoreInfoLogbook extends LitElement {
return html`
${isComponentLoaded(this.hass, "logbook")
? !this._logbookEntries
? this._error
? html`<div class="no-entries">
${`${this.hass.localize(
"ui.components.logbook.retrieval_error"
)}: ${this._error}`}
</div>`
: !this._logbookEntries
? html`
<ha-circular-progress
active
@@ -59,7 +69,7 @@ export class MoreInfoLogbook extends LitElement {
.hass=${this.hass}
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._persons}
.userIdToName=${this._userIdToName}
></ha-logbook>
`
: html`<div class="no-entries">
@@ -70,7 +80,7 @@ export class MoreInfoLogbook extends LitElement {
}
protected firstUpdated(): void {
this._fetchPersonNames();
this._fetchUserPromise = this._fetchUserNames();
this.addEventListener("click", (ev) => {
if ((ev.composedPath()[0] as HTMLElement).tagName === "A") {
setTimeout(() => closeDialog("ha-more-info-dialog"), 500);
@@ -116,16 +126,25 @@ export class MoreInfoLogbook extends LitElement {
this._lastLogbookDate ||
new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
const now = new Date();
const [newEntries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this.entityId,
true
),
loadTraceContexts(this.hass),
]);
let newEntries;
let traceContexts;
try {
[newEntries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this.entityId,
true
),
this.hass.user?.is_admin ? loadTraceContexts(this.hass) : {},
this._fetchUserPromise,
]);
} catch (err) {
this._error = err.message;
}
this._logbookEntries = this._logbookEntries
? [...newEntries, ...this._logbookEntries]
: newEntries;
@@ -133,16 +152,34 @@ export class MoreInfoLogbook extends LitElement {
this._traceContexts = traceContexts;
}
private _fetchPersonNames() {
private async _fetchUserNames() {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
Object.values(this.hass.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._persons[entity.attributes.user_id] =
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
}
static get styles() {
+11
View File
@@ -16,6 +16,8 @@ class HassLoadingScreen extends LitElement {
@property({ type: Boolean }) public rootnav = false;
@property() public header?: string;
@property() public narrow?: boolean;
protected render(): TemplateResult {
@@ -36,9 +38,13 @@ class HassLoadingScreen extends LitElement {
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
${this.header
? html`<div class="main-title">${this.header}</div>`
: ""}
</div>`}
<div class="content">
<ha-circular-progress active></ha-circular-progress>
<slot class="description"></slot>
</div>
`;
}
@@ -76,9 +82,14 @@ class HassLoadingScreen extends LitElement {
.content {
height: calc(100% - var(--header-height));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.description {
margin-top: 12px;
display: block;
}
`,
];
}
+7 -1
View File
@@ -12,7 +12,10 @@ import { HASSDomEvent } from "../common/dom/fire_event";
import { extractSearchParamsObject } from "../common/url/search-params";
import { subscribeOne } from "../common/util/subscribe-one";
import { AuthUrlSearchParams, hassUrl } from "../data/auth";
import { fetchDiscoveryInformation } from "../data/discovery";
import {
DiscoveryInformation,
fetchDiscoveryInformation,
} from "../data/discovery";
import {
fetchOnboardingOverview,
OnboardingResponses,
@@ -68,6 +71,8 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
@state() private _steps?: OnboardingStep[];
@state() private _discoveryInformation?: DiscoveryInformation;
protected render(): TemplateResult {
const step = this._curStep()!;
@@ -87,6 +92,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
? html`<onboarding-restore-snapshot
.localize=${this.localize}
.restoring=${this._restoring}
.discoveryInformation=${this._discoveryInformation}
@restoring=${this._restoringSnapshot}
>
</onboarding-restore-snapshot>`
+20 -7
View File
@@ -5,9 +5,11 @@ import "@polymer/paper-radio-button/paper-radio-button";
import "@polymer/paper-radio-group/paper-radio-group";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/map/ha-location-editor";
import "../components/map/ha-locations-editor";
import type { MarkerLocation } from "../components/map/ha-locations-editor";
import { createTimezoneListEl } from "../components/timezone-datalist";
import {
ConfigUpdateValues,
@@ -81,14 +83,14 @@ class OnboardingCoreConfig extends LitElement {
</div>
<div class="row">
<ha-location-editor
<ha-locations-editor
class="flex"
.hass=${this.hass}
.location=${this._locationValue}
.fitZoom=${14}
.locations=${this._markerLocation(this._locationValue)}
zoom="14"
.darkMode=${mql.matches}
@change=${this._locationChanged}
></ha-location-editor>
@location-updated=${this._locationChanged}
></ha-locations-editor>
</div>
<div class="row">
@@ -208,13 +210,24 @@ class OnboardingCoreConfig extends LitElement {
return this._unitSystem !== undefined ? this._unitSystem : "metric";
}
private _markerLocation = memoizeOne(
(location: [number, number]): MarkerLocation[] => [
{
id: "location",
latitude: location[0],
longitude: location[1],
location_editable: true,
},
]
);
private _handleChange(ev: PolymerChangedEvent<string>) {
const target = ev.currentTarget as PaperInputElement;
this[`_${target.name}`] = target.value;
}
private _locationChanged(ev) {
this._location = ev.currentTarget.location;
this._location = ev.detail.location;
}
private _unitSystemChanged(
+16 -8
View File
@@ -4,9 +4,12 @@ import { customElement, property } from "lit/decorators";
import "../../hassio/src/components/hassio-ansi-to-html";
import { showHassioSnapshotDialog } from "../../hassio/src/dialogs/snapshot/show-dialog-hassio-snapshot";
import { showSnapshotUploadDialog } from "../../hassio/src/dialogs/snapshot/show-dialog-snapshot-upload";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card";
import {
DiscoveryInformation,
fetchDiscoveryInformation,
} from "../data/discovery";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { ProvideHassLitMixin } from "../mixins/provide-hass-lit-mixin";
import { haStyle } from "../resources/styles";
@@ -26,6 +29,9 @@ class OnboardingRestoreSnapshot extends ProvideHassLitMixin(LitElement) {
@property({ type: Boolean }) public restoring = false;
@property({ attribute: false })
public discoveryInformation?: DiscoveryInformation;
protected render(): TemplateResult {
return this.restoring
? html`<ha-card
@@ -58,13 +64,14 @@ class OnboardingRestoreSnapshot extends ProvideHassLitMixin(LitElement) {
private async _checkRestoreStatus(): Promise<void> {
if (this.restoring) {
try {
const response = await fetch("/api/hassio/supervisor/info", {
method: "GET",
});
if (response.status === 401) {
// If we get a unauthorized response, the restore is done
navigate("/", { replace: true });
location.reload();
const response = await fetchDiscoveryInformation();
if (
!this.discoveryInformation ||
this.discoveryInformation.uuid !== response.uuid
) {
// When the UUID changes, the restore is complete
window.location.replace("/");
}
} catch (err) {
// We fully expected issues with fetching info untill restore is complete.
@@ -76,6 +83,7 @@ class OnboardingRestoreSnapshot extends ProvideHassLitMixin(LitElement) {
showHassioSnapshotDialog(this, {
slug,
onboarding: true,
localize: this.localize,
});
}
+29 -18
View File
@@ -8,7 +8,8 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const";
import "../../../components/ha-card";
import "../../../components/map/ha-location-editor";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { createTimezoneListEl } from "../../../components/timezone-datalist";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import type { PolymerChangedEvent } from "../../../polymer-types";
@@ -20,13 +21,13 @@ class ConfigCoreForm extends LitElement {
@state() private _working = false;
@state() private _location!: [number, number];
@state() private _location?: [number, number];
@state() private _elevation!: string;
@state() private _elevation?: string;
@state() private _unitSystem!: ConfigUpdateValues["unit_system"];
@state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@state() private _timeZone!: string;
@state() private _timeZone?: string;
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
@@ -52,16 +53,16 @@ class ConfigCoreForm extends LitElement {
: ""}
<div class="row">
<ha-location-editor
<ha-locations-editor
class="flex"
.hass=${this.hass}
.location=${this._locationValue(
this._location,
.locations=${this._markerLocation(
this.hass.config.latitude,
this.hass.config.longitude
this.hass.config.longitude,
this._location
)}
@change=${this._locationChanged}
></ha-location-editor>
@location-updated=${this._locationChanged}
></ha-locations-editor>
</div>
<div class="row">
@@ -162,8 +163,19 @@ class ConfigCoreForm extends LitElement {
input.inputElement.appendChild(createTimezoneListEl());
}
private _locationValue = memoizeOne(
(location, lat, lng) => location || [Number(lat), Number(lng)]
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private get _elevationValue() {
@@ -192,7 +204,7 @@ class ConfigCoreForm extends LitElement {
}
private _locationChanged(ev) {
this._location = ev.currentTarget.location;
this._location = ev.detail.location;
}
private _unitSystemChanged(
@@ -204,11 +216,10 @@ class ConfigCoreForm extends LitElement {
private async _save() {
this._working = true;
try {
const location = this._locationValue(
this._location,
const location = this._location || [
this.hass.config.latitude,
this.hass.config.longitude
);
this.hass.config.longitude,
];
await saveCoreConfig(this.hass, {
latitude: location[0],
longitude: location[1],
@@ -22,6 +22,7 @@ import {
enableConfigEntry,
reloadConfigEntry,
updateConfigEntry,
ERROR_STATES,
} from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
@@ -38,12 +39,6 @@ import type { HomeAssistant } from "../../../types";
import type { ConfigEntryExtended } from "./ha-config-integrations";
import "./ha-integration-header";
const ERROR_STATES: ConfigEntry["state"][] = [
"migration_error",
"setup_error",
"setup_retry",
];
const integrationsWithPanel = {
hassio: "/hassio/dashboard",
mqtt: "/config/mqtt",
@@ -303,7 +298,7 @@ export class HaIntegrationCard extends LitElement {
>
<ha-svg-icon .path=${mdiDotsVertical}></ha-svg-icon>
</mwc-icon-button>
<mwc-list-item @request-selected="${this._editEntryName}">
<mwc-list-item @request-selected="${this._handleRename}">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
@@ -420,6 +415,15 @@ export class HaIntegrationCard extends LitElement {
showOptionsFlowDialog(this, ev.target.closest("ha-card").configEntry);
}
private _handleRename(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._editEntryName(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _handleReload(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
@@ -578,8 +582,7 @@ export class HaIntegrationCard extends LitElement {
});
}
private async _editEntryName(ev) {
const configEntry = ev.target.closest("ha-card").configEntry;
private async _editEntryName(configEntry: ConfigEntry) {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: configEntry.title,
@@ -0,0 +1,312 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiStethoscope, mdiCheckCircle, mdiCloseCircle } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import {
fetchNetworkStatus,
healNetwork,
stopHealNetwork,
subscribeHealNetworkProgress,
ZWaveJSHealNetworkStatusMessage,
ZWaveJSNetwork,
} from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import { HomeAssistant } from "../../../../../types";
import { ZWaveJSHealNetworkDialogParams } from "./show-dialog-zwave_js-heal-network";
@customElement("dialog-zwave_js-heal-network")
class DialogZWaveJSHealNetwork extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string;
@state() private _status?: string;
@state() private _progress_total = 0;
@state() private _progress_finished = 0;
@state() private _progress_in_progress = 0;
private _subscribed?: Promise<UnsubscribeFunc>;
public showDialog(params: ZWaveJSHealNetworkDialogParams): void {
this._progress_total = 0;
this.entry_id = params.entry_id;
this._fetchData();
}
public closeDialog(): void {
this.entry_id = undefined;
this._status = undefined;
this._progress_total = 0;
this._unsubscribe();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this.entry_id) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.zwave_js.heal_network.title")
)}
>
${!this._status
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiStethoscope}
class="introduction"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.introduction"
)}
</p>
</div>
</div>
<p>
<em>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.traffic_warning"
)}
</em>
</p>
<mwc-button slot="primaryAction" @click=${this._startHeal}>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.start_heal"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.in_progress"
)}
</b>
</p>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.run_in_background"
)}
</p>
</div>
${!this._progress_total
? html`
<mwc-linear-progress indeterminate> </mwc-linear-progress>
`
: ""}
<mwc-button slot="secondaryAction" @click=${this._stopHeal}>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.stop_heal"
)}
</mwc-button>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.healing_failed"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.healing_complete"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._status === "cancelled"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_network.healing_cancelled"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._progress_total && this._status !== "finished"
? html`
<mwc-linear-progress
determinate
.progress=${this._progress_finished}
.buffer=${this._progress_in_progress}
>
</mwc-linear-progress>
`
: ""}
</ha-dialog>
`;
}
private async _fetchData(): Promise<void> {
if (!this.hass) {
return;
}
const network: ZWaveJSNetwork = await fetchNetworkStatus(
this.hass!,
this.entry_id!
);
if (network.controller.is_heal_network_active) {
this._status = "started";
this._subscribed = subscribeHealNetworkProgress(
this.hass,
this.entry_id!,
this._handleMessage.bind(this)
);
}
}
private _startHeal(): void {
if (!this.hass) {
return;
}
healNetwork(this.hass, this.entry_id!);
this._status = "started";
this._subscribed = subscribeHealNetworkProgress(
this.hass,
this.entry_id!,
this._handleMessage.bind(this)
);
}
private _stopHeal(): void {
if (!this.hass) {
return;
}
stopHealNetwork(this.hass, this.entry_id!);
this._unsubscribe();
this._status = "cancelled";
}
private _handleMessage(message: ZWaveJSHealNetworkStatusMessage): void {
if (message.event === "heal network progress") {
let finished = 0;
let in_progress = 0;
for (const status of Object.values(message.heal_node_status)) {
if (status === "pending") {
in_progress++;
}
if (["skipped", "failed", "done"].includes(status)) {
finished++;
}
}
this._progress_total = Object.keys(message.heal_node_status).length;
this._progress_finished = finished / this._progress_total;
this._progress_in_progress = in_progress / this._progress_total;
}
if (message.event === "heal network done") {
this._unsubscribe();
this._status = "finished";
}
}
private _unsubscribe(): void {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub());
this._subscribed = undefined;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.success {
color: var(--success-color);
}
.failed {
color: var(--warning-color);
}
.flex-container {
display: flex;
align-items: center;
}
ha-svg-icon {
width: 68px;
height: 48px;
}
ha-svg-icon.introduction {
color: var(--primary-color);
}
.flex-container ha-svg-icon {
margin-right: 20px;
}
mwc-linear-progress {
margin-top: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zwave_js-heal-network": DialogZWaveJSHealNetwork;
}
}
@@ -0,0 +1,19 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSHealNetworkDialogParams {
entry_id: string;
}
export const loadHealNetworkDialog = () =>
import("./dialog-zwave_js-heal-network");
export const showZWaveJSHealNetworkDialog = (
element: HTMLElement,
healNetworkDialogParams: ZWaveJSHealNetworkDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zwave_js-heal-network",
dialogImport: loadHealNetworkDialog,
dialogParams: healNetworkDialogParams,
});
};
@@ -1,6 +1,6 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-icon-button/mwc-icon-button";
import { mdiCheckCircle, mdiCircle, mdiRefresh } from "@mdi/js";
import { mdiAlertCircle, mdiCheckCircle, mdiCircle, mdiRefresh } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -17,6 +17,11 @@ import {
ZWaveJSNetwork,
ZWaveJSNode,
} from "../../../../../data/zwave_js";
import {
ConfigEntry,
getConfigEntries,
ERROR_STATES,
} from "../../../../../data/config_entries";
import {
showAlertDialog,
showConfirmationDialog,
@@ -27,8 +32,10 @@ import type { HomeAssistant, Route } from "../../../../../types";
import { fileDownload } from "../../../../../util/file_download";
import "../../../ha-config-section";
import { showZWaveJSAddNodeDialog } from "./show-dialog-zwave_js-add-node";
import { showZWaveJSHealNetworkDialog } from "./show-dialog-zwave_js-heal-network";
import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node";
import { configTabs } from "./zwave_js-config-router";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
@customElement("zwave_js-config-dashboard")
class ZWaveJSConfigDashboard extends LitElement {
@@ -42,6 +49,8 @@ class ZWaveJSConfigDashboard extends LitElement {
@property() public configEntryId?: string;
@state() private _configEntry?: ConfigEntry;
@state() private _network?: ZWaveJSNetwork;
@state() private _nodes?: ZWaveJSNode[];
@@ -59,6 +68,14 @@ class ZWaveJSConfigDashboard extends LitElement {
}
protected render(): TemplateResult {
if (!this._configEntry) {
return html``;
}
if (ERROR_STATES.includes(this._configEntry.state)) {
return this._renderErrorScreen();
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
@@ -162,6 +179,16 @@ class ZWaveJSConfigDashboard extends LitElement {
"ui.panel.config.zwave_js.common.remove_node"
)}
</mwc-button>
<mwc-button @click=${this._healNetworkClicked}>
${this.hass.localize(
"ui.panel.config.zwave_js.common.heal_network"
)}
</mwc-button>
<mwc-button @click=${this._openOptionFlow}>
${this.hass.localize(
"ui.panel.config.zwave_js.common.reconfigure_server"
)}
</mwc-button>
</div>
</ha-card>
<ha-card>
@@ -209,10 +236,83 @@ class ZWaveJSConfigDashboard extends LitElement {
`;
}
private _renderErrorScreen() {
const item = this._configEntry!;
let stateText: [string, ...unknown[]] | undefined;
let stateTextExtra: TemplateResult | string | undefined;
if (item.disabled_by) {
stateText = [
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
{
cause:
this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
) || item.disabled_by,
},
];
if (item.state === "failed_unload") {
stateTextExtra = html`.
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
)}.`;
}
} else if (item.state === "not_loaded") {
stateText = ["ui.panel.config.integrations.config_entry.not_loaded"];
} else if (ERROR_STATES.includes(item.state)) {
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
if (item.reason) {
this.hass.loadBackendTranslation("config", item.domain);
stateTextExtra = html` ${this.hass.localize(
`component.${item.domain}.config.error.${item.reason}`
) || item.reason}`;
} else {
stateTextExtra = html`
<br />
<a href="/config/logs"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}</a
>
`;
}
}
return html` ${stateText
? html`
<div class="error-message">
<ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
<h3>
${this._configEntry!.title}: ${this.hass.localize(...stateText)}
</h3>
<p>${stateTextExtra}</p>
<mwc-button @click=${this._handleBack}>
${this.hass?.localize("ui.panel.error.go_back") || "go back"}
</mwc-button>
</div>
`
: ""}`;
}
private _handleBack(): void {
history.back();
}
private async _fetchData() {
if (!this.configEntryId) {
return;
}
const configEntries = await getConfigEntries(this.hass);
this._configEntry = configEntries.find(
(entry) => entry.entry_id === this.configEntryId!
);
if (ERROR_STATES.includes(this._configEntry!.state)) {
return;
}
const [network, dataCollectionStatus] = await Promise.all([
fetchNetworkStatus(this.hass!, this.configEntryId),
fetchDataCollectionStatus(this.hass!, this.configEntryId),
@@ -254,6 +354,12 @@ class ZWaveJSConfigDashboard extends LitElement {
});
}
private async _healNetworkClicked() {
showZWaveJSHealNetworkDialog(this, {
entry_id: this.configEntryId!,
});
}
private _dataCollectionToggled(ev) {
setDataCollectionPreference(
this.hass!,
@@ -262,6 +368,17 @@ class ZWaveJSConfigDashboard extends LitElement {
);
}
private async _openOptionFlow() {
if (!this.configEntryId) {
return;
}
const configEntries = await getConfigEntries(this.hass);
const configEntry = configEntries.find(
(entry) => entry.entry_id === this.configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
private async _dumpDebugClicked() {
await this._fetchNodeStatus();
@@ -333,6 +450,27 @@ class ZWaveJSConfigDashboard extends LitElement {
color: red;
}
.error-message {
display: flex;
color: var(--primary-text-color);
height: calc(100% - var(--header-height));
padding: 16px;
align-items: center;
justify-content: center;
flex-direction: column;
}
.error-message h3 {
text-align: center;
font-weight: bold;
}
.error-message ha-svg-icon {
color: var(--error-color);
width: 64px;
height: 64px;
}
.content {
margin-top: 24px;
}
@@ -1,5 +1,6 @@
import "@polymer/paper-dropdown-menu/paper-dropdown-menu";
import "@polymer/paper-listbox/paper-listbox";
import { mdiDownload } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultArray, html, LitElement } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -13,6 +14,7 @@ import "../../../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../../resources/styles";
import { HomeAssistant, Route } from "../../../../../types";
import { fileDownload } from "../../../../../util/file_download";
import { configTabs } from "./zwave_js-config-router";
@customElement("zwave_js-logs")
@@ -31,16 +33,20 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
public hassSubscribe(): Array<UnsubscribeFunc | Promise<UnsubscribeFunc>> {
return [
subscribeZWaveJSLogs(this.hass, this.configEntryId, (log) => {
subscribeZWaveJSLogs(this.hass, this.configEntryId, (update) => {
if (!this.hasUpdated) {
return;
}
if (Array.isArray(log.message)) {
for (const line of log.message) {
this._textarea!.value += `${line}\n`;
if (update.type === "log_message") {
if (Array.isArray(update.log_message.message)) {
for (const line of update.log_message.message) {
this._textarea!.value += `${line}\n`;
}
} else {
this._textarea!.value += `${update.log_message.message}\n`;
}
} else {
this._textarea!.value += `${log.message}\n`;
this._logConfig = update.log_config;
}
}).then((unsub) => {
this._textarea!.value += `${this.hass.localize(
@@ -92,6 +98,14 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
`
: ""}
</div>
<mwc-icon-button
.label=${this.hass.localize(
"ui.panel.config.zwave_js.logs.download_logs"
)}
@click=${this._downloadLogs}
>
<ha-svg-icon .path=${mdiDownload}></ha-svg-icon>
</mwc-icon-button>
</ha-card>
<textarea readonly></textarea>
</div>
@@ -114,6 +128,14 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
);
}
private _downloadLogs() {
fileDownload(
this,
`data:text/plain;charset=utf-8,${encodeURI(this._textarea!.value)}`,
`zwave_js.log`
);
}
private _dropdownSelected(ev) {
if (ev.target === undefined || this._logConfig === undefined) {
return;
@@ -123,7 +145,6 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
return;
}
setZWaveJSLogLevel(this.hass!, this.configEntryId, selected);
this._logConfig.level = selected;
this._textarea!.value += `${this.hass.localize(
"ui.panel.config.zwave_js.logs.log_level_changed",
{ level: selected.charAt(0).toUpperCase() + selected.slice(1) }
+48 -21
View File
@@ -9,13 +9,9 @@ import { computeRTLDirection } from "../../../common/util/compute_rtl";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-formfield";
import "../../../components/ha-switch";
import "../../../components/map/ha-location-editor";
import {
defaultRadiusColor,
getZoneEditorInitData,
passiveRadiusColor,
ZoneMutableParams,
} from "../../../data/zone";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { getZoneEditorInitData, ZoneMutableParams } from "../../../data/zone";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
@@ -132,17 +128,19 @@ class DialogZoneDetail extends LitElement {
)}"
.invalid=${iconValid}
></paper-input>
<ha-location-editor
<ha-locations-editor
class="flex"
.hass=${this.hass}
.location=${this._locationValue(this._latitude, this._longitude)}
.radius=${this._radius}
.radiusColor=${this._passive
? passiveRadiusColor
: defaultRadiusColor}
.icon=${this._icon}
@change=${this._locationChanged}
></ha-location-editor>
.locations=${this._location(
this._latitude,
this._longitude,
this._radius,
this._passive,
this._icon
)}
@location-updated=${this._locationChanged}
@radius-updated=${this._radiusChanged}
></ha-locations-editor>
<div class="location">
<paper-input
.value=${this._latitude}
@@ -222,11 +220,40 @@ class DialogZoneDetail extends LitElement {
`;
}
private _locationValue = memoizeOne((lat, lng) => [Number(lat), Number(lng)]);
private _location = memoizeOne(
(
lat: number,
lng: number,
radius: number,
passive: boolean,
icon: string
): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
return [
{
id: "location",
latitude: Number(lat),
longitude: Number(lng),
radius,
radius_color: passive ? passiveRadiusColor : zoneRadiusColor,
icon,
location_editable: true,
radius_editable: true,
},
];
}
);
private _locationChanged(ev) {
[this._latitude, this._longitude] = ev.currentTarget.location;
this._radius = ev.currentTarget.radius;
private _locationChanged(ev: CustomEvent) {
[this._latitude, this._longitude] = ev.detail.location;
}
private _radiusChanged(ev: CustomEvent) {
this._radius = ev.detail.radius;
}
private _passiveChanged(ev) {
@@ -292,7 +319,7 @@ class DialogZoneDetail extends LitElement {
.location > *:last-child {
margin-left: 4px;
}
ha-location-editor {
ha-locations-editor {
margin-top: 16px;
}
a {
+16 -8
View File
@@ -31,11 +31,8 @@ import { saveCoreConfig } from "../../../data/core";
import { subscribeEntityRegistry } from "../../../data/entity_registry";
import {
createZone,
defaultRadiusColor,
deleteZone,
fetchZones,
homeRadiusColor,
passiveRadiusColor,
updateZone,
Zone,
ZoneMutableParams,
@@ -73,6 +70,15 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
private _getZones = memoizeOne(
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const homeRadiusColor = computedStyles.getPropertyValue(
"--primary-color"
);
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
id: entityState.entity_id,
@@ -86,7 +92,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
? homeRadiusColor
: entityState.attributes.passive
? passiveRadiusColor
: defaultRadiusColor,
: zoneRadiusColor,
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable: false,
@@ -94,7 +100,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zone.passive ? passiveRadiusColor : defaultRadiusColor,
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
location_editable: true,
radius_editable: true,
}));
@@ -274,7 +280,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
protected updated(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues) {
super.updated(changedProps);
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && this._stateItems) {
@@ -410,8 +416,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow) {
return;
}
await this.updateComplete;
this._activeEntry = created.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(created.id);
}
@@ -427,8 +434,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow || !fitMap) {
return;
}
await this.updateComplete;
this._activeEntry = entry.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(entry.id);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import "../../components/ha-circular-progress";
import "../../components/ha-date-range-picker";
import type { DateRangePickerRanges } from "../../components/ha-date-range-picker";
import "../../components/ha-menu-button";
import "../../components/state-history-charts";
import "../../components/chart/state-history-charts";
import { computeHistory, fetchDate } from "../../data/history";
import "../../layouts/ha-app-layout";
import { haStyle } from "../../resources/styles";
+37 -28
View File
@@ -1,24 +1,25 @@
import { mdiRefresh } from "@mdi/js";
import "@material/mwc-icon-button";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { css, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/ha-entity-picker";
import "../../components/ha-circular-progress";
import "../../components/ha-date-range-picker";
import type { DateRangePickerRanges } from "../../components/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-menu-button";
import {
clearLogbookCache,
getLogbookData,
LogbookEntry,
} from "../../data/logbook";
import { fetchPersons } from "../../data/person";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/ha-app-layout";
import { haStyle } from "../../resources/styles";
import { HomeAssistant } from "../../types";
@@ -44,7 +45,7 @@ export class HaPanelLogbook extends LitElement {
@state() private _ranges?: DateRangePickerRanges;
private _fetchUserDone?: Promise<unknown>;
private _fetchUserPromise?: Promise<void>;
@state() private _userIdToName = {};
@@ -136,7 +137,7 @@ export class HaPanelLogbook extends LitElement {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
this._fetchUserDone = this._fetchUserNames();
this._fetchUserPromise = this._fetchUserNames();
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -198,23 +199,19 @@ export class HaPanelLogbook extends LitElement {
private async _fetchUserNames() {
const userIdToName = {};
// Start loading all the data
const personProm = fetchPersons(this.hass);
const userProm = this.hass.user!.is_admin && fetchUsers(this.hass);
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
const persons = await personProm;
for (const person of persons.storage) {
if (person.user_id) {
userIdToName[person.user_id] = person.name;
Object.values(this.hass.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
}
for (const person of persons.config) {
if (person.user_id) {
userIdToName[person.user_id] = person.name;
}
}
});
// Process users
if (userProm) {
@@ -254,16 +251,28 @@ export class HaPanelLogbook extends LitElement {
private async _getData() {
this._isLoading = true;
const [entries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
this._startDate.toISOString(),
this._endDate.toISOString(),
this._entityId
),
isComponentLoaded(this.hass, "trace") ? loadTraceContexts(this.hass) : {},
this._fetchUserDone,
]);
let entries;
let traceContexts;
try {
[entries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
this._startDate.toISOString(),
this._endDate.toISOString(),
this._entityId
),
isComponentLoaded(this.hass, "trace") && this.hass.user?.is_admin
? loadTraceContexts(this.hass)
: {},
this._fetchUserPromise,
]);
} catch (err) {
showAlertDialog(this, {
title: this.hass.localize("ui.components.logbook.retrieval_error"),
text: err.message,
});
}
this._entries = entries;
this._traceContexts = traceContexts;
@@ -10,7 +10,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { throttle } from "../../../common/util/throttle";
import "../../../components/ha-card";
import "../../../components/state-history-charts";
import "../../../components/chart/state-history-charts";
import { CacheConfig, getRecentWithCache } from "../../../data/cached-history";
import { HistoryResult } from "../../../data/history";
import { HomeAssistant } from "../../../types";
@@ -139,8 +139,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.isLoadingData=${!this._stateHistory}
.historyData=${this._stateHistory}
.names=${this._names}
.upToNow=${true}
.noSingle=${true}
up-to-now
no-single
></state-history-charts>
</div>
</ha-card>
+54 -19
View File
@@ -9,11 +9,12 @@ import {
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { throttle } from "../../../common/util/throttle";
import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
import { fetchUsers } from "../../../data/user";
import { getLogbookData, LogbookEntry } from "../../../data/logbook";
import type { HomeAssistant } from "../../../types";
import "../../logbook/ha-logbook";
@@ -51,18 +52,22 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: LogbookCardConfig;
@state() private _logbookEntries?: LogbookEntry[];
@state() private _persons = {};
@state() private _configEntities?: EntityConfig[];
@state() private _userIdToName = {};
private _lastLogbookDate?: Date;
private _fetchUserPromise?: Promise<void>;
private _error?: string;
private _throttleGetLogbookEntries = throttle(() => {
this._getLogBookData();
}, 10000);
@@ -114,7 +119,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
}
protected firstUpdated(): void {
this._fetchPersonNames();
this._fetchUserPromise = this._fetchUserNames();
}
protected updated(changedProperties: PropertyValues) {
@@ -184,7 +189,15 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
class=${classMap({ "no-header": !this._config!.title })}
>
<div class="content">
${!this._logbookEntries
${this._error
? html`
<div class="no-entries">
${`${this.hass.localize(
"ui.components.logbook.retrieval_error"
)}: ${this._error}`}
</div>
`
: !this._logbookEntries
? html`
<ha-circular-progress
active
@@ -199,7 +212,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
virtualize
.hass=${this.hass}
.entries=${this._logbookEntries}
.userIdToName=${this._persons}
.userIdToName=${this._userIdToName}
></ha-logbook>
`
: html`
@@ -228,14 +241,22 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
);
const lastDate = this._lastLogbookDate || hoursToShowDate;
const now = new Date();
let newEntries;
const newEntries = await getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this._configEntities!.map((entity) => entity.entity).toString(),
true
);
try {
[newEntries] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this._configEntities!.map((entity) => entity.entity).toString(),
true
),
this._fetchUserPromise,
]);
} catch (err) {
this._error = err.message;
}
const logbookEntries = this._logbookEntries
? [...newEntries, ...this._logbookEntries]
@@ -248,20 +269,34 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
this._lastLogbookDate = now;
}
private _fetchPersonNames() {
if (!this.hass) {
return;
}
private async _fetchUserNames() {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
Object.values(this.hass!.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._persons[entity.attributes.user_id] =
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
}
static get styles(): CSSResultGroup {
+162 -478
View File
@@ -1,14 +1,5 @@
import { HassEntity } from "home-assistant-js-websocket";
import {
Circle,
CircleMarker,
LatLngTuple,
Layer,
Map,
Marker,
Polyline,
TileLayer,
} from "leaflet";
import { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { LatLngTuple } from "leaflet";
import {
css,
CSSResultGroup,
@@ -17,32 +8,93 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import {
LeafletModuleType,
replaceTileLayer,
setupLeafletMap,
} from "../../../common/dom/setup-leaflet-map";
import { customElement, property, query, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { debounce } from "../../../common/util/debounce";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import { fetchRecent } from "../../../data/history";
import { HomeAssistant } from "../../../types";
import "../../map/ha-entity-marker";
import "../../../components/map/ha-entity-marker";
import { findEntities } from "../common/find-entities";
import { installResizeObserver } from "../common/install-resize-observer";
import { processConfigEntities } from "../common/process-config-entities";
import { EntityConfig } from "../entity-rows/types";
import { LovelaceCard } from "../types";
import { MapCardConfig } from "./types";
import "../../../components/map/ha-map";
import { mdiImageFilterCenterFocus } from "@mdi/js";
import type { HaMap, HaMapPaths } from "../../../components/map/ha-map";
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../../../common/color/colors";
const MINUTE = 60000;
@customElement("hui-map-card")
class HuiMapCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public isPanel = false;
@state()
private _history?: HassEntity[][];
@state()
private _config?: MapCardConfig;
@query("ha-map")
private _map?: HaMap;
private _date?: Date;
private _configEntities?: string[];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
public setConfig(config: MapCardConfig): void {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities?.length && !config.geo_location_sources) {
throw new Error(
"Either entities or geo_location_sources must be specified"
);
}
if (config.entities && !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array");
}
if (
config.geo_location_sources &&
!Array.isArray(config.geo_location_sources)
) {
throw new Error("Geo_location_sources needs to be an array");
}
this._config = config;
this._configEntities = (config.entities
? processConfigEntities<EntityConfig>(config.entities)
: []
).map((entity) => entity.entity);
this._cleanupHistory();
}
public getCardSize(): number {
if (!this._config?.aspect_ratio) {
return 7;
}
const ratio = parseAspectRatio(this._config.aspect_ratio);
const ar =
ratio && ratio.w > 0 && ratio.h > 0
? `${((100 * ratio.h) / ratio.w).toFixed(2)}`
: "100";
return 1 + Math.floor(Number(ar) / 25) || 3;
}
public static async getConfigElement() {
await import("../editor/config-elements/hui-map-card-editor");
return document.createElement("hui-map-card-editor");
@@ -66,129 +118,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return { type: "map", entities: foundEntities };
}
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public isPanel = false;
@property()
private _history?: HassEntity[][];
private _date?: Date;
@property()
private _config?: MapCardConfig;
private _configEntities?: EntityConfig[];
// eslint-disable-next-line
private Leaflet?: LeafletModuleType;
private _leafletMap?: Map;
private _tileLayer?: TileLayer;
private _resizeObserver?: ResizeObserver;
private _debouncedResizeListener = debounce(
() => {
if (!this.isConnected || !this._leafletMap) {
return;
}
this._leafletMap.invalidateSize();
},
250,
false
);
private _mapItems: Array<Marker | Circle> = [];
private _mapZones: Array<Marker | Circle> = [];
private _mapPaths: Array<Polyline | CircleMarker> = [];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
private _colors: string[] = [
"#0288D1",
"#00AA00",
"#984ea3",
"#00d2d5",
"#ff7f00",
"#af8d00",
"#7f80cd",
"#b3e900",
"#c42e60",
"#a65628",
"#f781bf",
"#8dd3c7",
];
public setConfig(config: MapCardConfig): void {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities?.length && !config.geo_location_sources) {
throw new Error(
"Either entities or geo_location_sources must be specified"
);
}
if (config.entities && !Array.isArray(config.entities)) {
throw new Error("Entities need to be an array");
}
if (
config.geo_location_sources &&
!Array.isArray(config.geo_location_sources)
) {
throw new Error("Geo_location_sources needs to be an array");
}
this._config = config;
this._configEntities = config.entities
? processConfigEntities(config.entities)
: [];
this._cleanupHistory();
}
public getCardSize(): number {
if (!this._config?.aspect_ratio) {
return 7;
}
const ratio = parseAspectRatio(this._config.aspect_ratio);
const ar =
ratio && ratio.w > 0 && ratio.h > 0
? `${((100 * ratio.h) / ratio.w).toFixed(2)}`
: "100";
return 1 + Math.floor(Number(ar) / 25) || 3;
}
public connectedCallback(): void {
super.connectedCallback();
this._attachObserver();
if (this.hasUpdated) {
this.loadMap();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._leafletMap) {
this._leafletMap.remove();
this._leafletMap = undefined;
this.Leaflet = undefined;
}
if (this._resizeObserver) {
this._resizeObserver.unobserve(this._mapEl);
}
}
protected render(): TemplateResult {
if (!this._config) {
return html``;
@@ -196,22 +125,29 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return html`
<ha-card id="card" .header=${this._config.title}>
<div id="root">
<div
id="map"
class=${classMap({ dark: this._config.dark_mode === true })}
></div>
<ha-icon-button
<ha-map
.hass=${this.hass}
.entities=${this._getEntities(
this.hass.states,
this._config,
this._configEntities
)}
.paths=${this._getHistoryPaths(this._config, this._history)}
.darkMode=${this._config.dark_mode}
></ha-map>
<mwc-icon-button
@click=${this._fitMap}
tabindex="0"
icon="hass:image-filter-center-focus"
title="Reset focus"
></ha-icon-button>
>
<ha-svg-icon .path=${mdiImageFilterCenterFocus}></ha-svg-icon>
</mwc-icon-button>
</div>
</ha-card>
`;
}
protected shouldUpdate(changedProps) {
protected shouldUpdate(changedProps: PropertyValues) {
if (!changedProps.has("hass") || changedProps.size > 1) {
return true;
}
@@ -228,7 +164,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
// Check if any state has changed
for (const entity of this._configEntities) {
if (oldHass.states[entity.entity] !== this.hass!.states[entity.entity]) {
if (oldHass.states[entity] !== this.hass!.states[entity]) {
return true;
}
}
@@ -238,17 +174,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
if (this.isConnected) {
this.loadMap();
}
const root = this.shadowRoot!.getElementById("root");
if (!this._config || this.isPanel || !root) {
return;
}
this._attachObserver();
if (!this._config.aspect_ratio) {
root.style.paddingBottom = "100%";
return;
@@ -263,172 +194,86 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("hass") || changedProps.has("_history")) {
this._drawEntities();
this._fitMap();
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && oldHass.themes.darkMode !== this.hass.themes.darkMode) {
this._replaceTileLayer();
}
}
if (
changedProps.has("_config") &&
changedProps.get("_config") !== undefined
) {
this.updateMap(changedProps.get("_config") as MapCardConfig);
}
if (this._config?.hours_to_show && this._configEntities?.length) {
const minute = 60000;
if (changedProps.has("_config")) {
this._getHistory();
} else if (Date.now() - this._date!.getTime() >= minute) {
} else if (Date.now() - this._date!.getTime() >= MINUTE) {
this._getHistory();
}
}
}
private get _mapEl(): HTMLDivElement {
return this.shadowRoot!.getElementById("map") as HTMLDivElement;
private _fitMap() {
this._map?.fitMap();
}
private async loadMap(): Promise<void> {
[this._leafletMap, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this._mapEl,
this._config!.dark_mode ?? this.hass.themes.darkMode
);
this._drawEntities();
this._leafletMap.invalidateSize();
this._fitMap();
}
private _replaceTileLayer() {
const map = this._leafletMap;
const config = this._config;
const Leaflet = this.Leaflet;
if (!map || !config || !Leaflet || !this._tileLayer) {
return;
}
this._tileLayer = replaceTileLayer(
Leaflet,
map,
this._tileLayer,
this._config!.dark_mode ?? this.hass.themes.darkMode
);
}
private updateMap(oldConfig: MapCardConfig): void {
const map = this._leafletMap;
const config = this._config;
const Leaflet = this.Leaflet;
if (!map || !config || !Leaflet || !this._tileLayer) {
return;
}
if (this._config!.dark_mode !== oldConfig.dark_mode) {
this._replaceTileLayer();
}
if (
config.entities !== oldConfig.entities ||
config.geo_location_sources !== oldConfig.geo_location_sources
) {
this._drawEntities();
}
map.invalidateSize();
this._fitMap();
}
private _fitMap(): void {
if (!this._leafletMap || !this.Leaflet || !this._config || !this.hass) {
return;
}
const zoom = this._config.default_zoom;
if (this._mapItems.length === 0) {
this._leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
zoom || 14
);
return;
}
const bounds = this.Leaflet.featureGroup(this._mapItems).getBounds();
this._leafletMap.fitBounds(bounds.pad(0.5));
if (zoom && this._leafletMap.getZoom() > zoom) {
this._leafletMap.setZoom(zoom);
}
}
private _getColor(entityId: string) {
let color;
if (this._colorDict[entityId]) {
color = this._colorDict[entityId];
} else {
color = this._colors[this._colorIndex];
this._colorIndex = (this._colorIndex + 1) % this._colors.length;
this._colorDict[entityId] = color;
private _getColor(entityId: string): string {
let color = this._colorDict[entityId];
if (color) {
return color;
}
color = getColorByIndex(this._colorIndex);
this._colorIndex++;
this._colorDict[entityId] = color;
return color;
}
private _drawEntities(): void {
const hass = this.hass;
const map = this._leafletMap;
const config = this._config;
const Leaflet = this.Leaflet;
if (!hass || !map || !config || !Leaflet) {
return;
}
if (this._mapItems) {
this._mapItems.forEach((marker) => marker.remove());
}
const mapItems: Layer[] = (this._mapItems = []);
if (this._mapZones) {
this._mapZones.forEach((marker) => marker.remove());
}
const mapZones: Layer[] = (this._mapZones = []);
if (this._mapPaths) {
this._mapPaths.forEach((marker) => marker.remove());
}
const mapPaths: Layer[] = (this._mapPaths = []);
const allEntities = this._configEntities!.concat();
// Calculate visible geo location sources
if (config.geo_location_sources) {
const includesAll = config.geo_location_sources.includes("all");
for (const entityId of Object.keys(hass.states)) {
const stateObj = hass.states[entityId];
if (
computeDomain(entityId) === "geo_location" &&
(includesAll ||
config.geo_location_sources.includes(stateObj.attributes.source))
) {
allEntities.push({ entity: entityId });
}
private _getEntities = memoizeOne(
(
states: HassEntities,
config: MapCardConfig,
configEntities?: string[]
) => {
if (!states || !config) {
return undefined;
}
}
// DRAW history
if (this._config!.hours_to_show && this._history) {
for (const entityStates of this._history) {
let entities = configEntities || [];
if (config.geo_location_sources) {
const geoEntities: string[] = [];
// Calculate visible geo location sources
const includesAll = config.geo_location_sources.includes("all");
for (const stateObj of Object.values(states)) {
if (
computeDomain(stateObj.entity_id) === "geo_location" &&
(includesAll ||
config.geo_location_sources.includes(stateObj.attributes.source))
) {
geoEntities.push(stateObj.entity_id);
}
}
entities = [...entities, ...geoEntities];
}
return entities.map((entity) => ({
entity_id: entity,
color: this._getColor(entity),
}));
}
);
private _getHistoryPaths = memoizeOne(
(
config: MapCardConfig,
history?: HassEntity[][]
): HaMapPaths[] | undefined => {
if (!config.hours_to_show || !history) {
return undefined;
}
const paths: HaMapPaths[] = [];
for (const entityStates of history) {
if (entityStates?.length <= 1) {
continue;
}
const entityId = entityStates[0].entity_id;
// filter location data from states and remove all invalid locations
const path = entityStates.reduce(
(accumulator: LatLngTuple[], state) => {
const latitude = state.attributes.latitude;
const longitude = state.attributes.longitude;
const points = entityStates.reduce(
(accumulator: LatLngTuple[], entityState) => {
const latitude = entityState.attributes.latitude;
const longitude = entityState.attributes.longitude;
if (latitude && longitude) {
accumulator.push([latitude, longitude] as LatLngTuple);
}
@@ -437,162 +282,15 @@ class HuiMapCard extends LitElement implements LovelaceCard {
[]
) as LatLngTuple[];
// DRAW HISTORY
for (
let markerIndex = 0;
markerIndex < path.length - 1;
markerIndex++
) {
const opacityStep = 0.8 / (path.length - 2);
const opacity = 0.2 + markerIndex * opacityStep;
// DRAW history path dots
mapPaths.push(
Leaflet.circleMarker(path[markerIndex], {
radius: 3,
color: this._getColor(entityId),
opacity,
interactive: false,
})
);
// DRAW history path lines
const line = [path[markerIndex], path[markerIndex + 1]];
mapPaths.push(
Leaflet.polyline(line, {
color: this._getColor(entityId),
opacity,
interactive: false,
})
);
}
paths.push({
points,
color: this._getColor(entityStates[0].entity_id),
gradualOpacity: 0.8,
});
}
return paths;
}
// DRAW entities
for (const entity of allEntities) {
const entityId = entity.entity;
const stateObj = hass.states[entityId];
if (!stateObj) {
continue;
}
const title = computeStateName(stateObj);
const {
latitude,
longitude,
passive,
icon,
radius,
entity_picture: entityPicture,
gps_accuracy: gpsAccuracy,
} = stateObj.attributes;
if (!(latitude && longitude)) {
continue;
}
if (computeStateDomain(stateObj) === "zone") {
// DRAW ZONE
if (passive) {
continue;
}
// create icon
let iconHTML = "";
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconHTML = el.outerHTML;
} else {
const el = document.createElement("span");
el.innerHTML = title;
iconHTML = el.outerHTML;
}
// create marker with the icon
mapZones.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: this._config!.dark_mode
? "dark"
: this._config!.dark_mode === false
? "light"
: "",
}),
interactive: false,
title,
})
);
// create circle around it
mapZones.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: "#FF9800",
radius,
})
);
continue;
}
// DRAW ENTITY
// create icon
const entityName = title
.split(" ")
.map((part) => part[0])
.join("")
.substr(0, 3);
// create market with the icon
mapItems.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
// Leaflet clones this element before adding it to the map. This messes up
// our Polymer object and we can't pass data through. Thus we hack like this.
html: `
<ha-entity-marker
entity-id="${entityId}"
entity-name="${entityName}"
entity-picture="${entityPicture || ""}"
entity-color="${this._getColor(entityId)}"
></ha-entity-marker>
`,
iconSize: [48, 48],
className: "",
}),
title: computeStateName(stateObj),
})
);
// create circle around if entity has accuracy
if (gpsAccuracy) {
mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: this._getColor(entityId),
radius: gpsAccuracy,
})
);
}
}
this._mapItems.forEach((marker) => map.addLayer(marker));
this._mapZones.forEach((marker) => map.addLayer(marker));
this._mapPaths.forEach((marker) => map.addLayer(marker));
}
private async _attachObserver(): Promise<void> {
// Observe changes to map size and invalidate to prevent broken rendering
if (!this._resizeObserver) {
await installResizeObserver();
this._resizeObserver = new ResizeObserver(this._debouncedResizeListener);
}
this._resizeObserver.observe(this);
}
);
private async _getHistory(): Promise<void> {
this._date = new Date();
@@ -601,9 +299,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return;
}
const entityIds = this._configEntities!.map((entity) => entity.entity).join(
","
);
const entityIds = this._configEntities!.join(",");
const endTime = new Date();
const startTime = new Date();
startTime.setHours(endTime.getHours() - this._config!.hours_to_show!);
@@ -624,7 +320,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
if (stateHistory.length < 1) {
return;
}
this._history = stateHistory;
}
@@ -636,13 +331,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._history = undefined;
} else {
// remove unused entities
const configEntityIds = this._configEntities?.map(
(configEntity) => configEntity.entity
);
this._history = this._history!.reduce(
(accumulator: HassEntity[][], entityStates) => {
const entityId = entityStates[0].entity_id;
if (configEntityIds?.includes(entityId)) {
if (this._configEntities?.includes(entityId)) {
accumulator.push(entityStates);
}
return accumulator;
@@ -660,7 +352,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
height: 100%;
}
#map {
ha-map {
z-index: 0;
border: none;
position: absolute;
@@ -671,7 +363,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
background: inherit;
}
ha-icon-button {
mwc-icon-button {
position: absolute;
top: 75px;
left: 3px;
@@ -685,14 +377,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
:host([ispanel]) #root {
height: 100%;
}
.dark {
color: #ffffff;
}
.light {
color: #000000;
}
`;
}
}
@@ -447,47 +447,37 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
--name-font-size: 1.2rem;
--brightness-font-size: 1.2rem;
--rail-border-color: transparent;
--auto-color: green;
--eco-color: springgreen;
--cool-color: #2b9af9;
--heat-color: #ff8100;
--manual-color: #44739e;
--off-color: #8a8a8a;
--fan_only-color: #8a8a8a;
--dry-color: #efbd07;
--idle-color: #8a8a8a;
--unknown-color: #bac;
}
.auto,
.heat_cool {
--mode-color: var(--auto-color);
--mode-color: var(--state-climate-auto-color);
}
.cool {
--mode-color: var(--cool-color);
--mode-color: var(--state-climate-cool-color);
}
.heat {
--mode-color: var(--heat-color);
--mode-color: var(--state-climate-heat-color);
}
.manual {
--mode-color: var(--manual-color);
--mode-color: var(--state-climate-manual-color);
}
.off {
--mode-color: var(--off-color);
--mode-color: var(--state-climate-off-color);
}
.fan_only {
--mode-color: var(--fan_only-color);
--mode-color: var(--state-climate-fan_only-color);
}
.eco {
--mode-color: var(--eco-color);
--mode-color: var(--state-climate-eco-color);
}
.dry {
--mode-color: var(--dry-color);
--mode-color: var(--state-climate-dry-color);
}
.idle {
--mode-color: var(--idle-color);
--mode-color: var(--state-climate-idle-color);
}
.unknown-mode {
--mode-color: var(--unknown-color);
--mode-color: var(--state-unknown-color);
}
.more-info {
@@ -33,18 +33,18 @@ export class HuiActionEditor extends LitElement {
@property() protected hass?: HomeAssistant;
get _navigation_path(): string {
const config = this.config as NavigateActionConfig;
return config.navigation_path || "";
const config = this.config as NavigateActionConfig | undefined;
return config?.navigation_path || "";
}
get _url_path(): string {
const config = this.config as UrlActionConfig;
return config.url_path || "";
const config = this.config as UrlActionConfig | undefined;
return config?.url_path || "";
}
get _service(): string {
const config = this.config as CallServiceActionConfig;
return config.service || "";
return config?.service || "";
}
private _serviceAction = memoizeOne(
@@ -29,6 +29,7 @@ export class HuiInputListEditor extends LitElement {
.index=${index}
@value-changed=${this._valueChanged}
@blur=${this._consolidateEntries}
@keydown=${this._handleKeyDown}
><ha-icon-button
slot="suffix"
class="clear-button"
@@ -70,6 +71,13 @@ export class HuiInputListEditor extends LitElement {
});
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
ev.stopPropagation();
this._consolidateEntries(ev);
}
}
private _consolidateEntries(ev: Event): void {
const target = ev.target! as EditorTarget;
if (target.value === "") {
@@ -37,6 +37,7 @@ const LAZY_LOAD_TYPES = {
"input-text-entity": () => import("../entity-rows/hui-input-text-entity-row"),
"lock-entity": () => import("../entity-rows/hui-lock-entity-row"),
"number-entity": () => import("../entity-rows/hui-number-entity-row"),
"select-entity": () => import("../entity-rows/hui-select-entity-row"),
"timer-entity": () => import("../entity-rows/hui-timer-entity-row"),
conditional: () => import("../special-rows/hui-conditional-row"),
"weather-entity": () => import("../entity-rows/hui-weather-entity-row"),
@@ -68,6 +69,7 @@ const DOMAIN_TO_ELEMENT_TYPE = {
remote: "toggle",
scene: "scene",
script: "script",
select: "select",
sensor: "sensor",
timer: "timer",
switch: "toggle",
@@ -81,6 +81,7 @@ export class HuiGraphFooterEditor
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._hours_to_show}
min="1"
.configValue=${"hours_to_show"}
@value-changed=${this._valueChanged}
></paper-input>
@@ -79,6 +79,7 @@ export class HuiHistoryGraphCardEditor
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value="${this._hours_to_show}"
min="1"
.configValue=${"hours_to_show"}
@value-changed="${this._valueChanged}"
></paper-input>
@@ -85,6 +85,7 @@ export class HuiLogbookCardEditor
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._hours_to_show}
min="1"
.configValue=${"hours_to_show"}
@value-changed=${this._valueChanged}
></paper-input>
@@ -34,7 +34,7 @@ const cardConfigStruct = object({
dark_mode: optional(boolean()),
entities: array(entitiesConfigStruct),
hours_to_show: optional(number()),
geo_location_sources: optional(array()),
geo_location_sources: optional(array(string())),
});
@customElement("hui-map-card-editor")
@@ -177,6 +177,7 @@ export class HuiSensorCardEditor
)})"
type="number"
.value=${this._hours_to_show}
min="1"
.configValue=${"hours_to_show"}
@value-changed=${this._valueChanged}
></paper-input>
@@ -11,7 +11,7 @@ import { customElement, property, state } from "lit/decorators";
import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { computeRTLDirection } from "../../../common/util/compute_rtl";
import "../../../components/ha-slider";
import { UNAVAILABLE_STATES } from "../../../data/entity";
import { UNAVAILABLE } from "../../../data/entity";
import { setValue } from "../../../data/input_text";
import { HomeAssistant } from "../../../types";
import { hasConfigOrEntityChanged } from "../common/has-changed";
@@ -75,7 +75,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
? html`
<div class="flex">
<ha-slider
.disabled=${UNAVAILABLE_STATES.includes(stateObj.state)}
.disabled=${stateObj.state === UNAVAILABLE}
.dir=${computeRTLDirection(this.hass)}
.step="${Number(stateObj.attributes.step)}"
.min="${Number(stateObj.attributes.min)}"
@@ -101,7 +101,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
<paper-input
no-label-float
auto-validate
.disabled=${UNAVAILABLE_STATES.includes(stateObj.state)}
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
.step="${Number(stateObj.attributes.step)}"
.min="${Number(stateObj.attributes.min)}"
@@ -0,0 +1,186 @@
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { DOMAINS_HIDE_MORE_INFO } from "../../../common/const";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/entity/state-badge";
import "../../../components/ha-paper-dropdown-menu";
import { UNAVAILABLE } from "../../../data/entity";
import { forwardHaptic } from "../../../data/haptics";
import { SelectEntity, setSelectOption } from "../../../data/select";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
import { EntitiesCardEntityConfig } from "../cards/types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import { LovelaceRow } from "./types";
@customElement("hui-select-entity-row")
class HuiSelectEntityRow extends LitElement implements LovelaceRow {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EntitiesCardEntityConfig;
public setConfig(config: EntitiesCardEntityConfig): void {
if (!config || !config.entity) {
throw new Error("Entity must be specified");
}
this._config = config;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return hasConfigOrEntityChanged(this, changedProps);
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
const stateObj = this.hass.states[this._config.entity] as
| SelectEntity
| undefined;
if (!stateObj) {
return html`
<hui-warning>
${createEntityNotFoundWarning(this.hass, this._config.entity)}
</hui-warning>
`;
}
const pointer =
(this._config.tap_action && this._config.tap_action.action !== "none") ||
(this._config.entity &&
!DOMAINS_HIDE_MORE_INFO.includes(computeDomain(this._config.entity)));
return html`
<state-badge
.stateObj=${stateObj}
.overrideIcon=${this._config.icon}
.overrideImage=${this._config.image}
class=${classMap({
pointer,
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
})}
tabindex=${ifDefined(pointer ? "0" : undefined)}
></state-badge>
<ha-paper-dropdown-menu
.label=${this._config.name || computeStateName(stateObj)}
.disabled=${stateObj.state === UNAVAILABLE}
@iron-select=${this._selectedChanged}
@click=${stopPropagation}
>
<paper-listbox slot="dropdown-content">
${stateObj.attributes.options
? stateObj.attributes.options.map(
(option) =>
html`
<paper-item .option=${option}
>${(stateObj.attributes.device_class &&
this.hass!.localize(
`component.select.state.${stateObj.attributes.device_class}.${option}`
)) ||
this.hass!.localize(
`component.select.state._.${option}`
) ||
option}</paper-item
>
`
)
: ""}
</paper-listbox>
</ha-paper-dropdown-menu>
`;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!this.hass || !this._config) {
return;
}
const stateObj = this.hass.states[this._config.entity] as
| SelectEntity
| undefined;
if (!stateObj) {
return;
}
// Update selected after rendering the items or else it won't work in Firefox
if (stateObj.attributes.options) {
this.shadowRoot!.querySelector(
"paper-listbox"
)!.selected = stateObj.attributes.options.indexOf(stateObj.state);
}
}
private _handleAction(ev: ActionHandlerEvent) {
handleAction(this, this.hass!, this._config!, ev.detail.action!);
}
static get styles(): CSSResultGroup {
return css`
:host {
display: flex;
align-items: center;
}
ha-paper-dropdown-menu {
margin-left: 16px;
flex: 1;
}
paper-item {
cursor: pointer;
min-width: 200px;
}
.pointer {
cursor: pointer;
}
state-badge:focus {
outline: none;
background: var(--divider-color);
border-radius: 100%;
}
`;
}
private _selectedChanged(ev): void {
const stateObj = this.hass!.states[this._config!.entity];
const option = ev.target.selectedItem.option;
if (option === stateObj.state) {
return;
}
forwardHaptic("light");
setSelectOption(this.hass!, stateObj.entity_id, option);
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-select-entity-row": HuiSelectEntityRow;
}
}
-88
View File
@@ -1,88 +0,0 @@
import "@polymer/iron-image/iron-image";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { EventsMixin } from "../../mixins/events-mixin";
/*
* @appliesMixin EventsMixin
*/
class HaEntityMarker extends EventsMixin(PolymerElement) {
static get template() {
return html`
<style include="iron-positioning"></style>
<style>
.marker {
position: relative;
display: block;
margin: 0 auto;
width: 2.5em;
text-align: center;
height: 2.5em;
line-height: 2.5em;
font-size: 1.5em;
border-radius: 50%;
border: 0.1em solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
iron-image {
border-radius: 50%;
}
</style>
<div class="marker" style$="border-color:{{entityColor}}">
<template is="dom-if" if="[[entityName]]">[[entityName]]</template>
<template is="dom-if" if="[[entityPicture]]">
<iron-image
sizing="cover"
class="fit"
src="[[entityPicture]]"
></iron-image>
</template>
</div>
`;
}
static get properties() {
return {
hass: {
type: Object,
},
entityId: {
type: String,
value: "",
},
entityName: {
type: String,
value: null,
},
entityPicture: {
type: String,
value: null,
},
entityColor: {
type: String,
value: null,
},
};
}
ready() {
super.ready();
this.addEventListener("click", (ev) => this.badgeTap(ev));
}
badgeTap(ev) {
ev.stopPropagation();
if (this.entityId) {
this.fire("hass-more-info", { entityId: this.entityId });
}
}
}
customElements.define("ha-entity-marker", HaEntityMarker);
-263
View File
@@ -1,263 +0,0 @@
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import {
replaceTileLayer,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { navigate } from "../../common/navigate";
import "../../components/ha-icon";
import "../../components/ha-menu-button";
import { defaultRadiusColor } from "../../data/zone";
import "../../layouts/ha-app-layout";
import LocalizeMixin from "../../mixins/localize-mixin";
import "../../styles/polymer-ha-style";
import "./ha-entity-marker";
/*
* @appliesMixin LocalizeMixin
*/
class HaPanelMap extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style include="ha-style">
#map {
height: calc(100vh - var(--header-height));
width: 100%;
z-index: 0;
background: inherit;
}
.icon {
color: var(--primary-text-color);
}
</style>
<ha-app-layout>
<app-header fixed slot="header">
<app-toolbar>
<ha-menu-button
hass="[[hass]]"
narrow="[[narrow]]"
></ha-menu-button>
<div main-title>[[localize('panel.map')]]</div>
<template is="dom-if" if="[[computeShowEditZone(hass)]]">
<ha-icon-button
icon="hass:pencil"
on-click="openZonesEditor"
></ha-icon-button>
</template>
</app-toolbar>
</app-header>
<div id="map"></div>
</ha-app-layout>
`;
}
static get properties() {
return {
hass: {
type: Object,
observer: "drawEntities",
},
narrow: Boolean,
};
}
connectedCallback() {
super.connectedCallback();
this.loadMap();
}
async loadMap() {
this._darkMode = this.hass.themes.darkMode;
[this._map, this.Leaflet, this._tileLayer] = await setupLeafletMap(
this.$.map,
this._darkMode
);
this.drawEntities(this.hass);
this._map.invalidateSize();
this.fitMap();
}
disconnectedCallback() {
if (this._map) {
this._map.remove();
}
}
computeShowEditZone(hass) {
return !__DEMO__ && hass.user.is_admin;
}
openZonesEditor() {
navigate("/config/zone");
}
fitMap() {
let bounds;
if (this._mapItems.length === 0) {
this._map.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
14
);
} else {
bounds = new this.Leaflet.latLngBounds(
this._mapItems.map((item) => item.getLatLng())
);
this._map.fitBounds(bounds.pad(0.5));
}
}
drawEntities(hass) {
/* eslint-disable vars-on-top */
const map = this._map;
if (!map) return;
if (this._darkMode !== this.hass.themes.darkMode) {
this._darkMode = this.hass.themes.darkMode;
this._tileLayer = replaceTileLayer(
this.Leaflet,
map,
this._tileLayer,
this.hass.themes.darkMode
);
}
if (this._mapItems) {
this._mapItems.forEach(function (marker) {
marker.remove();
});
}
const mapItems = (this._mapItems = []);
if (this._mapZones) {
this._mapZones.forEach(function (marker) {
marker.remove();
});
}
const mapZones = (this._mapZones = []);
Object.keys(hass.states).forEach((entityId) => {
const entity = hass.states[entityId];
if (
entity.state === "home" ||
!("latitude" in entity.attributes) ||
!("longitude" in entity.attributes)
) {
return;
}
const title = computeStateName(entity);
let icon;
if (computeStateDomain(entity) === "zone") {
// DRAW ZONE
if (entity.attributes.passive) return;
// create icon
let iconHTML = "";
if (entity.attributes.icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", entity.attributes.icon);
iconHTML = el.outerHTML;
} else {
const el = document.createElement("span");
el.innerHTML = title;
iconHTML = el.outerHTML;
}
icon = this.Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className: "icon",
});
// create marker with the icon
mapZones.push(
this.Leaflet.marker(
[entity.attributes.latitude, entity.attributes.longitude],
{
icon: icon,
interactive: false,
title: title,
}
).addTo(map)
);
// create circle around it
mapZones.push(
this.Leaflet.circle(
[entity.attributes.latitude, entity.attributes.longitude],
{
interactive: false,
color: defaultRadiusColor,
radius: entity.attributes.radius,
}
).addTo(map)
);
return;
}
// DRAW ENTITY
// create icon
const entityPicture = entity.attributes.entity_picture || "";
const entityName = title
.split(" ")
.map(function (part) {
return part.substr(0, 1);
})
.join("");
/* Leaflet clones this element before adding it to the map. This messes up
our Polymer object and we can't pass data through. Thus we hack like this. */
icon = this.Leaflet.divIcon({
html:
"<ha-entity-marker entity-id='" +
entity.entity_id +
"' entity-name='" +
entityName +
"' entity-picture='" +
entityPicture +
"'></ha-entity-marker>",
iconSize: [45, 45],
className: "",
});
// create market with the icon
mapItems.push(
this.Leaflet.marker(
[entity.attributes.latitude, entity.attributes.longitude],
{
icon: icon,
title: computeStateName(entity),
}
).addTo(map)
);
// create circle around if entity has accuracy
if (entity.attributes.gps_accuracy) {
mapItems.push(
this.Leaflet.circle(
[entity.attributes.latitude, entity.attributes.longitude],
{
interactive: false,
color: "#0288D1",
radius: entity.attributes.gps_accuracy,
}
).addTo(map)
);
}
});
}
}
customElements.define("ha-panel-map", HaPanelMap);
+103
View File
@@ -0,0 +1,103 @@
import { mdiPencil } from "@mdi/js";
import "@material/mwc-icon-button";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { property } from "lit/decorators";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-svg-icon";
import "../../components/ha-menu-button";
import "../../layouts/ha-app-layout";
import { HomeAssistant } from "../../types";
import "../../components/map/ha-map";
import { haStyle } from "../../resources/styles";
class HaPanelMap extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
private _entities: string[] = [];
protected render() {
return html`
<ha-app-layout>
<app-header fixed slot="header">
<app-toolbar>
<ha-menu-button
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div main-title>${this.hass.localize("panel.map")}</div>
${!__DEMO__ && this.hass.user?.is_admin
? html`<mwc-icon-button @click=${this._openZonesEditor}
><ha-svg-icon .path=${mdiPencil}></ha-svg-icon
></mwc-icon-button>`
: ""}
</app-toolbar>
</app-header>
<ha-map .hass=${this.hass} .entities=${this._entities} autoFit></ha-map>
</ha-app-layout>
`;
}
private _openZonesEditor() {
navigate("/config/zone");
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
this._getStates(oldHass);
}
private _getStates(oldHass?: HomeAssistant) {
let changed = false;
const personSources = new Set<string>();
const locationEntities: string[] = [];
Object.values(this.hass!.states).forEach((entity) => {
if (
entity.state === "home" ||
!("latitude" in entity.attributes) ||
!("longitude" in entity.attributes)
) {
return;
}
locationEntities.push(entity.entity_id);
if (computeStateDomain(entity) === "person" && entity.attributes.source) {
personSources.add(entity.attributes.source);
}
if (oldHass?.states[entity.entity_id] !== entity) {
changed = true;
}
});
if (changed) {
this._entities = locationEntities.filter(
(entity) => !personSources.has(entity)
);
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-map {
height: calc(100vh - var(--header-height));
}
`,
];
}
}
customElements.define("ha-panel-map", HaPanelMap);
declare global {
interface HTMLElementTagNameMap {
"ha-panel-map": HaPanelMap;
}
}
@@ -1,5 +1,4 @@
import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import "@polymer/iron-label/iron-label";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
+35
View File
@@ -0,0 +1,35 @@
import {
LineController,
TimeScale,
LinearScale,
PointElement,
LineElement,
Filler,
Legend,
Title,
Tooltip,
CategoryScale,
Chart,
} from "chart.js";
import { TextBarElement } from "../components/chart/timeline-chart/textbar-element";
import { TimelineController } from "../components/chart/timeline-chart/timeline-controller";
import { TimeLineScale } from "../components/chart/timeline-chart/timeline-scale";
import "../components/chart/chart-date-adapter";
export { Chart } from "chart.js";
Chart.register(
Tooltip,
Title,
Legend,
Filler,
TimeScale,
LinearScale,
LineController,
PointElement,
LineElement,
TextBarElement,
TimeLineScale,
TimelineController,
CategoryScale
);
-60
View File
@@ -1,60 +0,0 @@
import Chart from "chart.js";
import "chartjs-chart-timeline";
// This function add a new interaction mode to Chart.js that
// returns one point for every dataset.
Chart.Interaction.modes.neareach = function (chart, e, options) {
const getRange = {
x: (a, b) => Math.abs(a.x - b.x),
y: (a, b) => Math.abs(a.y - b.y),
// eslint-disable-next-line no-restricted-properties
xy: (a, b) => Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2),
};
const getRangeMax = {
x: (r) => r,
y: (r) => r,
xy: (r) => r * r,
};
let position;
if (e.native) {
position = {
x: e.x,
y: e.y,
};
} else {
position = Chart.helpers.getRelativePosition(e, chart);
}
const elements = [];
const elementsRange = [];
const datasets = chart.data.datasets;
let meta;
options.axis = options.axis || "xy";
const rangeFunc = getRange[options.axis];
const rangeMaxFunc = getRangeMax[options.axis];
for (let i = 0, ilen = datasets.length; i < ilen; ++i) {
if (!chart.isDatasetVisible(i)) {
continue;
}
meta = chart.getDatasetMeta(i);
for (let j = 0, jlen = meta.data.length; j < jlen; ++j) {
const element = meta.data[j];
if (!element._view.skip) {
const vm = element._view;
const range = rangeFunc(vm, position);
const oldRange = elementsRange[i];
if (range < rangeMaxFunc(vm.radius + vm.hitRadius)) {
if (oldRange === undefined || oldRange > range) {
elementsRange[i] = range;
elements[i] = element;
}
}
}
}
}
const ret = elements.filter((n) => n !== undefined);
return ret;
};
export default Chart;
+28 -6
View File
@@ -29,10 +29,10 @@ documentContainer.innerHTML = `<custom-style>
--disabled-text-color: #bdbdbd;
/* main interface colors */
--primary-color: #03a9f4;
--primary-color: ${DEFAULT_PRIMARY_COLOR};
--dark-primary-color: #0288d1;
--light-primary-color: #b3e5fC;
--accent-color: #ff9800;
--accent-color: ${DEFAULT_ACCENT_COLOR};
--divider-color: rgba(0, 0, 0, .12);
--scrollbar-thumb-color: rgb(194, 194, 194);
@@ -42,10 +42,6 @@ documentContainer.innerHTML = `<custom-style>
--success-color: #0f9d58;
--info-color: #4285f4;
/* states and badges */
--state-icon-color: #44739e;
--state-icon-active-color: #FDD835;
/* background and sidebar */
--card-background-color: #ffffff;
--primary-background-color: #fafafa;
@@ -60,6 +56,32 @@ documentContainer.innerHTML = `<custom-style>
--label-badge-green: #0DA035;
--label-badge-yellow: #f4b400;
/* states and badges */
--state-icon-color: #44739e;
/* an active state is anything that would require attention */
--state-icon-active-color: #FDD835;
/* an error state is anything that would be considered an error */
/* --state-icon-error-color: #db4437; derived from error-color */
--state-on-color: #66a61e;
--state-off-color: #ff0029;
--state-home-color: #66a61e;
--state-not_home-color: #ff0029;
/* --state-unavailable-color: #a0a0a0; derived from disabled-text-color */
--state-unknown-color: #606060;
--state-idle-color: #377eb8;
/* climate state colors */
--state-climate-auto-color: #008000;
--state-climate-eco-color: #00ff7f;
--state-climate-cool-color: #2b9af9;
--state-climate-heat-color: #ff8100;
--state-climate-manual-color: #44739e;
--state-climate-off-color: #8a8a8a;
--state-climate-fan_only-color: #8a8a8a;
--state-climate-dry-color: #efbd07;
--state-climate-idle-color: #8a8a8a;
/*
Paper-styles color.html dependency is stripped on build.
When a default paper-style color is used, it needs to be copied
+3 -2
View File
@@ -34,8 +34,9 @@ export const darkStyles = {
};
export const derivedStyles = {
"error-state-color": "var(--error-color)",
"state-icon-unavailable-color": "var(--disabled-text-color)",
"state-icon-error-color": "var(--error-state-color, var(--error-color))",
"state-unavailable-color":
"var(--state-icon-unavailable-color, var(--disabled-text-color))",
"sidebar-text-color": "var(--primary-text-color)",
"sidebar-background-color": "var(--card-background-color)",
"sidebar-selected-text-color": "var(--primary-color)",
+1
View File
@@ -14,6 +14,7 @@ import "./state-card-media_player";
import "./state-card-number";
import "./state-card-scene";
import "./state-card-script";
import "./state-card-select";
import "./state-card-timer";
import "./state-card-toggle";
import "./state-card-vacuum";
+99
View File
@@ -0,0 +1,99 @@
import "@polymer/paper-dropdown-menu/paper-dropdown-menu-light";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeStateName } from "../common/entity/compute_state_name";
import "../components/entity/state-badge";
import { SelectEntity, setSelectOption } from "../data/select";
import type { HomeAssistant } from "../types";
@customElement("state-card-select")
class StateCardSelect extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public stateObj!: SelectEntity;
protected render(): TemplateResult {
return html`
<state-badge .stateObj=${this.stateObj}></state-badge>
<paper-dropdown-menu-light
.label=${computeStateName(this.stateObj)}
@iron-select=${this._selectedOptionChanged}
@click=${stopPropagation}
>
<paper-listbox slot="dropdown-content">
${this.stateObj.attributes.options.map(
(option) =>
html`
<paper-item .option=${option}
>${(this.stateObj.attributes.device_class &&
this.hass.localize(
`component.select.state.${this.stateObj.attributes.device_class}.${option}`
)) ||
this.hass.localize(`component.select.state._.${option}`) ||
option}</paper-item
>
`
)}
</paper-listbox>
</paper-dropdown-menu-light>
`;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!changedProps.has("stateObj")) {
return;
}
// Update selected after rendering the items or else it won't work in Firefox
this.shadowRoot!.querySelector(
"paper-listbox"
)!.selected = this.stateObj.attributes.options.indexOf(this.stateObj.state);
}
private _selectedOptionChanged(ev) {
const option = ev.target.selectedItem.option;
if (option === this.stateObj.state) {
return;
}
setSelectOption(this.hass, this.stateObj.entity_id, option);
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
}
state-badge {
float: left;
margin-top: 10px;
}
paper-dropdown-menu-light {
display: block;
margin-left: 53px;
}
paper-item {
cursor: pointer;
min-width: 200px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"state-card-select": StateCardSelect;
}
}
+40 -4
View File
@@ -304,6 +304,7 @@
"by": "by",
"by_service": "by service",
"show_trace": "Show trace",
"retrieval_error": "Error during logbook entry retrieval",
"messages": {
"was_away": "was detected away",
"was_at_state": "was detected at {state}",
@@ -2120,7 +2121,7 @@
"confirm_new": "Do you want to set up {integration}?",
"add_integration": "Add integration",
"no_integrations": "Seems like you don't have any integrations configured yet. Click on the button below to add your first integration!",
"note_about_integrations": "Not all integrations can be configured via the UI yet.",
"note_about_integrations": "No integrations matched your search, the integration you want to set up might not be available to set up via the UI yet.",
"note_about_website_reference": "More are available on the ",
"home_assistant_website": "Home Assistant website",
"configure": "Configure",
@@ -2586,7 +2587,9 @@
"home_id": "Home ID",
"close": "Close",
"add_node": "Add Node",
"remove_node": "Remove Node"
"remove_node": "Remove Node",
"reconfigure_server": "Re-configure Server",
"heal_network": "Heal Network"
},
"dashboard": {
"header": "Manage your Z-Wave Network",
@@ -2668,6 +2671,18 @@
"interview_failed": "The device interview failed. Additional information may be available in the logs.",
"interview_complete": "Device interview complete."
},
"heal_network": {
"title": "Heal your Z-Wave Network",
"introduction": "Start a network heal on your Z-Wave network. A network heal will cause all devices to re-calculate their routes back to the controller and is recommended if you have recently moved devices or your controller.",
"traffic_warning": "The healing process generates a large amount of traffic on the Z-Wave network. This may cause devices to respond slowly (or not at all) while the heal is in progress.",
"start_heal": "Start Healing",
"in_progress": "Network healing is in progress. This will take some time.",
"run_in_background": "You can close this dialog and the network healing will continue in the background.",
"stop_heal": "Stop Healing",
"healing_complete": "Network healing is complete.",
"healing_failed": "Healing failed. Additional information may be available in the logs.",
"healing_cancelled": "Network healing has been cancelled."
},
"logs": {
"title": "Z-Wave JS Logs",
"log_level": "Log Level",
@@ -3569,7 +3584,16 @@
"description": "Alternatively you can restore from a previous snapshot.",
"in_progress": "Restore in progress",
"show_log": "Show full log",
"hide_log": "Hide full log"
"hide_log": "Hide full log",
"full_snapshot": "[%key:supervisor::snapshot::full_snapshot%]",
"partial_snapshot": "[%key:supervisor::snapshot::partial_snapshot%]",
"type": "[%key:supervisor::snapshot::type%]",
"select_type": "[%key:supervisor::snapshot::select_type%]",
"folders": "[%key:supervisor::snapshot::folders%]",
"addons": "[%key:supervisor::snapshot::addons%]",
"password_protection": "[%key:supervisor::snapshot::password_protection%]",
"password": "[%key:supervisor::snapshot::password%]",
"confirm_password": "[%key:supervisor::snapshot::confirm_password%]"
}
},
"custom": {
@@ -3813,7 +3837,7 @@
"faq_link": "[%key:ui::panel::my::faq_link%]",
"error": "[%key:ui::panel::my::error%]",
"error_addon_not_found": "Add-on not found",
"error_addon_not_started": "The requested add-on are not running. Please start it first",
"error_addon_not_started": "The requested add-on is not running. Please start it first",
"error_addon_not_installed": "The requested add-on is not installed. Please install it first",
"error_addon_no_ingress": "The requested add-on does not support ingress"
},
@@ -3924,9 +3948,11 @@
"addons": "Add-ons",
"folders": "Folders",
"password": "Snapshot password",
"confirm_password": "Confirm Snapshot password",
"password_protection": "Password protection",
"password_protected": "password protected",
"enter_password": "Please enter a password.",
"passwords_not_matching": "The passwords does not match",
"folder": {
"homeassistant": "Home Assistant configuration",
"ssl": "SSL",
@@ -3990,5 +4016,15 @@
"device_path": "Device path"
}
}
},
"ingress": {
"not_running": "[%key:supervisor::my::error_addon_not_started%]",
"waiting": "Waiting for add-on to start",
"unable_to_create": "Unable to create an Ingress session",
"no_ingress": "[%key:supervisor::my::error_addon_no_ingress%]",
"unable_to_fetch": "Unable to fetch add-on info to start Ingress",
"timeout": "Timeout while waiting for add-on to start, check the add-on logs",
"go_to_dashboard": "Go to add-on dashboard",
"go_to_logs": "Go to add-on logs"
}
}
@@ -236,6 +236,98 @@ describe("computeStateDisplay", () => {
});
});
describe("Localizes input_datetime state parameter with full date time", () => {
const stateObj: any = {
entity_id: "input_datetime.test",
state: "123",
attributes: {
has_date: true,
has_time: true,
year: 2021,
month: 6,
day: 13,
hour: 15,
minute: 26,
second: 36,
},
};
it("Uses am/pm time format", () => {
assert.strictEqual(
computeStateDisplay(
localize,
stateObj,
localeData,
"2021-07-04 15:40:03"
),
"July 4, 2021, 3:40 PM"
);
});
it("Uses 24h time format", () => {
localeData.time_format = TimeFormat.twenty_four;
assert.strictEqual(
computeStateDisplay(
localize,
stateObj,
localeData,
"2021-07-04 15:40:03"
),
"July 4, 2021, 15:40"
);
});
});
it("Localizes input_datetime state parameter with date", () => {
const stateObj: any = {
entity_id: "input_datetime.test",
state: "123",
attributes: {
has_date: true,
has_time: false,
year: 2021,
month: 6,
day: 13,
hour: 15,
minute: 26,
second: 36,
},
};
assert.strictEqual(
computeStateDisplay(localize, stateObj, localeData, "2021-07-04"),
"July 4, 2021"
);
});
describe("Localizes input_datetime state parameter with time", () => {
const stateObj: any = {
entity_id: "input_datetime.test",
state: "123",
attributes: {
has_date: false,
has_time: true,
year: 2021,
month: 6,
day: 13,
hour: 15,
minute: 26,
second: 36,
},
};
it("Uses am/pm time format", () => {
localeData.time_format = TimeFormat.am_pm;
assert.strictEqual(
computeStateDisplay(localize, stateObj, localeData, "17:05:07"),
"5:05 PM"
);
});
it("Uses 24h time format", () => {
localeData.time_format = TimeFormat.twenty_four;
assert.strictEqual(
computeStateDisplay(localize, stateObj, localeData, "17:05:07"),
"17:05"
);
});
});
it("Localizes unavailable", () => {
const altLocalize = (message, ...args) => {
if (message === "state.sensor.unavailable") {
+89 -22
View File
@@ -130,12 +130,12 @@
"action_error": {
"get_changelog": "Неуспешно получаване на списък с промени на добавката",
"go_to_config": "Грешка при стартиране на добавката - неуспешна проверка на конфигурацията!",
"install": "Инсталирането на добавката не бе успешно",
"restart": "Рестартирането на добавката не бе успешно",
"start": "Стартирането на добавката не бе успешно",
"install": "Инсталирането на добавката не е успешно",
"restart": "Рестартирането на добавката не е успешно",
"start": "Стартирането на добавката не е успешно",
"start_invalid_config": "Към конфигурацията",
"stop": "Спирането на добавката не бе успешно",
"uninstall": "Деинсталирането на добавката не бе успешно",
"stop": "Спирането на добавката не е успешно",
"uninstall": "Деинсталирането на добавката не е успешно",
"validate_config": "Грешка при проверка на конфигурацията на добавката"
},
"capability": {
@@ -218,8 +218,8 @@
"documentation": {
"get_documentation": "Неуспешно получаване на документация за добавката, {error}"
},
"failed_to_reset": "Нулирането на конфигурацията на добавката не бе успешно, {error}",
"failed_to_save": "Запазването на конфигурацията на добавката не бе успешно, {error}",
"failed_to_reset": "Нулирането на конфигурацията на добавката не е успешно, {error}",
"failed_to_save": "Запазването на конфигурацията на добавката не е успешно, {error}",
"logs": {
"get_logs": "Неуспешно получаване журнали на добавка, {грешка}"
},
@@ -241,10 +241,10 @@
"description": "Описание",
"error": {
"unknown": "Неизвестна грешка",
"update_failed": "Актуализацията не бе успешна"
"update_failed": "Актуализацията не е успешна"
},
"failed_to_restart_name": "Рестартирането на {name} не бе успешно",
"failed_to_update_name": "Актуализирането на {name} не бе успешно",
"failed_to_restart_name": "Рестартирането на {name} не е успешно",
"failed_to_update_name": "Актуализирането на {name} не е успешно",
"learn_more": "Научете повече",
"new_version_available": "Налична е нова версия",
"newest_version": "Последна версия",
@@ -260,6 +260,7 @@
"save": "Запис",
"show_more": "Показване на повече информация за това",
"update": "Актуализиране",
"update_available": "{count}{count, plural,\n one {обновление изчаква}\n other {{count} обновления изчакват}\n}",
"version": "Версия",
"yes": "Да"
},
@@ -285,12 +286,20 @@
"no_addons": "Все още нямате инсталирани добавки. Насочете се към хранилището за добавки, за да започнете!"
},
"dialog": {
"hardware": {
"attributes": "Атрибути",
"device_path": "Път до устройството",
"id": "ID",
"search": "Търсене на хардуер",
"subsystem": "Подсистема",
"title": "Хардуер"
},
"network": {
"connected_to": "Свързан с {ssid}",
"dhcp": "DHCP",
"disabled": "Деактивиран",
"dns_servers": "DNS Сървъри",
"failed_to_change": "Промяната на мрежовите настройки не бе успешна",
"failed_to_change": "Промяната на мрежовите настройки не е успешна",
"gateway": "Адрес на шлюза",
"ip_netmask": "IP адрес/Мрежова маска",
"open": "Отворена",
@@ -305,8 +314,8 @@
"registries": {
"add_new_registry": "Добавяне на нов регистър",
"add_registry": "Добавяне на регистър",
"failed_to_add": "Добавянето на регистър не бе успешно",
"failed_to_remove": "Премахването на регистър не бе успешно",
"failed_to_add": "Добавянето на регистър не е успешно",
"failed_to_remove": "Премахването на регистър не е успешно",
"no_registries": "Няма конфигурирани регистри",
"password": "Парола",
"registry": "Регистър",
@@ -334,6 +343,8 @@
"my": {
"error": "Възникна неизвестна грешка",
"error_addon_not_found": "Не е намерена добавка",
"error_addon_not_installed": "Исканата добавка не е инсталирана. Моля, първо я инсталирайте",
"error_addon_not_started": "Заявената добавка не е стартирана. Моля, първо я стартирайте",
"faq_link": "My Home Assistant ЧЗВ",
"not_supported": "Тази препратка не се поддържа от вашата Home Assistant инсталация. Последвайте {link} за поддържани препратки както и версиите при тяхното пускане."
},
@@ -346,6 +357,7 @@
"snapshot": {
"addons": "Добавки",
"available_snapshots": "Налични снапшоти",
"confirm_password": "Потвърдете паролата за снапшота",
"could_not_create": "Не можа да се създаде снапшот",
"create": "Създаване",
"create_blocked_not_running": "Създаването на снапшот в момента не е възможно, тъй като системата е в състояние {state}.",
@@ -353,9 +365,11 @@
"created": "Създаден",
"delete_selected": "Изтриване на избраните снапшоти",
"delete_snapshot_confirm": "Изтрий",
"delete_snapshot_title": зтриване на снапшот",
"delete_snapshot_text": скате ли да изтриете {number} {number, plural,\n one {резервно копие}\n other {резервни копия}\n}?",
"delete_snapshot_title": "Изтриване на снапшота",
"description": "Снапшотите ви позволяват лесно да архивирате и възстановявате всички данни от вашия екземпляр на Home Assistant.",
"enter_password": "Моля, въведете парола.",
"failed_to_delete": "Изтриването не е успешно",
"folder": {
"addons/local": "Локални добавки",
"homeassistant": "Конфигурация на Home Assistant",
@@ -371,7 +385,9 @@
"password": "Парола",
"password_protected": "защитен с парола",
"password_protection": "Защита с парола",
"passwords_not_matching": "Паролите не съвпадат",
"security": "Сигурност",
"select_type": "Изберете какво да възстановите",
"selected": "{number} избрани",
"type": "Тип",
"upload_snapshot": "Качване на снапшот"
@@ -439,7 +455,7 @@
"unhealthy_reason": {
"docker": "Средата на Docker не работи правилно",
"privileged": "Supervisor не е привилегирован",
"setup": "Настройката на Supervisor не бе успешна",
"setup": "Настройката на Supervisor не е успешна",
"supervisor": "Supervisor не успя да се актуализира",
"untrusted": "Открито e ненадежден съдържание"
},
@@ -878,6 +894,8 @@
"config_entry_system_options": {
"enable_new_entities_description": "Ако е изключено, новооткритите обекти за {integration} няма да бъдат автоматично добавяни в Home Assistant",
"enable_new_entities_label": "Активирай новодобавените обекти.",
"enable_polling_description": "Дали Home Assistant трябва да обновява автоматично обектите от {integration}.",
"enable_polling_label": "Включи автоматично опресняване",
"restart_home_assistant": "Трябва да рестартирате Home Assistant, за да влязат в сила промените.",
"title": "Системни опции за {integration}",
"update": "Актуализация"
@@ -1034,6 +1052,7 @@
"areas": "Области",
"automation": "Автоматизации",
"blueprint": "Планове",
"core": "Общи",
"customize": "Персонализации",
"devices": "Устройства",
"entities": "Обекти",
@@ -1066,6 +1085,7 @@
"rpi_gpio": "Raspberry Pi GPIO обекти",
"scene": "Презареждане на сцените",
"script": "Презареждане на скриптовете",
"smtp": "SMTP услуги за уведомяване",
"telegram": "Презареждане на Telegram услугите за нотификация",
"zone": "Презареждане на зоните"
},
@@ -1093,7 +1113,7 @@
"buttons": {
"add": "Добавете устройства чрез това устройство",
"clusters": "Управление на клъстери",
"device_children": "Преглед на дъщерни",
"device_children": "Преглед на дъщерните устройства",
"reconfigure": "Преконфигуриране на устройството",
"remove": "Премахване на устройството",
"zigbee_information": "Подпис на Zigbee устройството"
@@ -1120,14 +1140,17 @@
},
"zha_reconfigure_device": {
"attribute": "Атрибут",
"battery_device_warning": "Ще трябва да събудите устройствата, захранвани с батерии, преди да започнете процеса на преконфигуриране. Вижте ръководствата на вашите устройства за инструкции как да ги събудите.",
"bind_header": "Обвързване",
"button_hide": "Скрийте детайлите",
"button_show": "Покажи детайли",
"cluster_header": "Клъстер",
"configuration_complete": "Преконфигурирането на устройството завърши.",
"configuration_failed": "Преконфигурирането на устройството не бе успешно. Допълнителна информация може да бъде налична в дневниците.",
"configuring_alt": "Конфигуриране",
"heading": "Преконфигуриране на устройство",
"in_progress": "Устройството се преконфигурира. Това може да отнеме известно време.",
"introduction": "Преконфигуриране на устройство във вашата Zigbee мрежа. Използвайте тази функция, ако устройството ви не функционира правилно.",
"min_max_change": "мин/макс/промяна",
"reporting_header": "Отчитане",
"run_in_background": "Можете да затворите този диалогов прозорец и преконфигурирането ще продължи във фонов режим.",
@@ -1194,6 +1217,7 @@
},
"description": "Групирайте устройствата и обектите в области",
"editor": {
"area_id": "ID на област",
"create": "Създаване",
"default_name": "Нова област",
"delete": "Изтриване",
@@ -1642,7 +1666,8 @@
"link_learn_how_it_works": "Научете как работи",
"not_connected": "Не е свързан",
"remote_enabled": {
"caption": "Автоматично свързване"
"caption": "Автоматично свързване",
"description": "Активирайте тази опция, за да сте сигурни, че вашият Home Assistant е винаги достъпен от разстояние."
},
"title": "Дистанционен контрол"
},
@@ -1921,7 +1946,8 @@
"scripts": "Скриптове",
"unknown_error": "Неизвестна грешка",
"unnamed_device": "Устройство без име",
"update": "Актуализация"
"update": "Актуализация",
"update_device_error": "Актуализирането на устройството не е успешно"
},
"entities": {
"caption": "Обекти",
@@ -2033,6 +2059,7 @@
"depends_on_cloud": "Зависи от облака",
"device_unavailable": "Устройството е недостъпно",
"devices": "{count} {count, plural,\n one {устройство}\n other {устройства}\n}",
"disable_error": "Активирането или деактивирането на интеграцията не бе успешно",
"disable_restart_confirm": "Рестартирайте Home Assistant за да завършите деактивирането на тази интеграция",
"disable": {
"disable_confirm": "Наистина ли искате да забраните този конфигурационен запис? Устройствата и обекти му ще бъдат деактивирани.",
@@ -2044,6 +2071,7 @@
},
"disabled_cause": "Деактивирано от {cause}"
},
"disabled_polling": "Автоматичното обновяване за данни е забранено",
"documentation": "Документация",
"enable_restart_confirm": "Рестартирайте Home Assistant за да завършите активирането на тази интеграция",
"entities": "{count} {count, plural,\n one {обект}\n other {обекта}\n}",
@@ -2214,6 +2242,7 @@
"common": {
"controller": "Контролер",
"network": "Мрежа",
"node_id": "ID на възел",
"query_stage": "Етап на заявка",
"wakeup_instructions": "Инструкции за събуждане",
"zwave": "Z-Wave"
@@ -2221,6 +2250,7 @@
"navigation": {
"network": "Мрежа",
"node": {
"config": "Конфигуриране",
"dashboard": "Табло"
},
"nodes": "Възли"
@@ -2241,11 +2271,15 @@
"network": {
"node_count": "{count} възли"
},
"node_config": {
"header": "Конфигурация на възел"
},
"node_metadata": {
"product_manual": "Ръководство за продукта"
},
"node_query_stages": {
"associations": "Обновяване на свързаните групи и членства"
"associations": "Обновяване на свързаните групи и членства",
"complete": "Разпита приключи"
},
"node": {
"button": "Детайли за възела",
@@ -2261,9 +2295,11 @@
},
"refresh_node": {
"button": "Обнови възела",
"description": "Това ще накара OpenZWave да разпита дадено устройство и да актуализира командните му класове, възможностите и стойностите му.",
"node_status": "Състояние на възела",
"refreshing_description": "Опресняване на информацията за възела...",
"step": "Стъпка"
"step": "Стъпка",
"wakeup_header": "Инструкции за събуждане за"
},
"select_instance": {
"none_found": "Не можахме да намерим OpenZWave инстанция. Ако смятате че не е вярно, проверете Вашите OpenZWare и MQTT и се уверете. че Home Assistant може да комуникира със MQTT брокера."
@@ -2299,6 +2335,7 @@
"learn_more": "Научете повече за хората",
"no_persons_created_yet": "Изглежда все още не сте добавили хора.",
"note_about_persons_configured_in_yaml": "Забележка: хората, конфигурирани чрез configuration.yaml, не могат да бъдат редактирани чрез потребителския интерфейс.",
"person_not_found": "Не успяхме да открием лицето, което се опитвате да редактирате.",
"person_not_found_title": "Не е намерено лице"
},
"scene": {
@@ -2330,6 +2367,8 @@
"add_scene": "Добавяне на сцена",
"delete_confirm": "Сигурни ли сте, че искате да изтриете тази сцена?",
"delete_scene": "Изтриване на сцената",
"duplicate": "Дублиране",
"duplicate_scene": "Дублиране на сцената",
"edit_scene": "Редактиране на сцената",
"header": "Редактор на сцени",
"headers": {
@@ -2533,8 +2572,10 @@
"CONFIGURED_status_text": "Инициализация",
"INITIALIZED": "Инициализацията завърши",
"INITIALIZED_status_text": "Устройството е готово за употреба",
"INTERVIEW_COMPLETE": "Разпита приключи",
"INTERVIEW_COMPLETE_status_text": "Конфигуриране",
"PAIRED": "Намерено устройство"
"PAIRED": "Намерено устройство",
"PAIRED_status_text": "Започване на разпит"
},
"groups": {
"add_group": "Добавяне на група",
@@ -2590,6 +2631,9 @@
"zwave_js": {
"add_node": {
"inclusion_failed": "Възелът не може да бъде добавен. Моля, проверете журналите за повече информация.",
"inclusion_finished": "Възелът е добавен.",
"interview_failed": "Разпита на устройството не бе успешен. Допълнителна информация може да е налична в логовете.",
"interview_started": "Устройството се разпитва. Това може да отнеме известно време.",
"title": "Добавяне на Z-Wave възел"
},
"button": "Конфигуриране",
@@ -2627,12 +2671,23 @@
"header": "Z-Wave конфигурация на устройството",
"introduction": "Управление и настройване на специфични конфигурационни параметри на подбраното устройството (възел, node)",
"parameter_is_read_only": "Този параметър е само за четене.",
"set_param_accepted": "Параметърът е актуализиран.",
"set_param_error": "Възникна грешка.",
"zwave_js_device_database": "Z-Wave JS база данни с устройства"
},
"node_status": {
"unknown": "Неизвестен"
},
"reinterview_node": {
"battery_device_warning": "Ще трябва да събудите устройствата, захранвани с батерии, преди да започнете повторното интервю. Вижте ръководството на вашето устройство за инструкции как да го събудите.",
"in_progress": "Устройството се разпитва. Това може да отнеме известно време.",
"interview_complete": "Разпита на устройството приключи.",
"interview_failed": "Разпита на устройството не бе успешен. Допълнителна информация може да е налична в логовете.",
"introduction": "Повторно разпитване на устройство във вашата Z-Wave мрежа. Използвайте тази функция, ако устройството има липсваща или неправилна функционалност.",
"run_in_background": "Можете да затворите този диалогов прозорец и разпита ще продължи във фонов режим.",
"start_reinterview": "Започване на нов разпит",
"title": "Повторен разпит на Z-Wave устройство"
},
"remove_node": {
"exclusion_failed": "Възелът не може да бъде премахнат. Моля, проверете журналите за повече информация.",
"title": "Премахване на Z-Wave възел"
@@ -2683,6 +2738,7 @@
"node_protection": "Защита на възела",
"nodes": "Възли",
"nodes_in_group": "Други възли в тази група:",
"pooling_intensity": "Честота на опресняване",
"protection": "Защита",
"remove_from_group": "Премахване от групата"
},
@@ -2900,6 +2956,7 @@
"tilt-position": "Наклон"
},
"show_header_toggle": "Показване на превключване на заглавка?",
"special_row": "специален ред",
"toggle": "Превключване на обекти."
},
"entity-filter": {
@@ -3015,6 +3072,7 @@
"cardpicker": {
"by_card": "По карта",
"by_entity": "По обект",
"custom_card": "Персонализирана",
"domain": "Домейн",
"entity": "Обект",
"no_description": "Няма налично описание."
@@ -3351,10 +3409,19 @@
"intro": "Готови ли сте да събудите дома си, да отвоювате независимостта си и да се присъедините към световна общност от хора автоматизиращи домовете си?",
"next": "Следващ",
"restore": {
"addons": "Добавки",
"confirm_password": "Потвърдете паролата за снапшота",
"description": "Като алтернатива можете да възстановите от предишен снапшот.",
"folders": "Папки",
"full_snapshot": "Пълен снапшот",
"hide_log": "Скриване на пълния дневник",
"in_progress": "Възстановяването е в ход",
"show_log": "Показване на пълния дневник"
"partial_snapshot": "Частичен снапшот",
"password": "Парола",
"password_protection": "Защита с парола",
"select_type": "Изберете какво да възстановите",
"show_log": "Показване на пълния дневник",
"type": "Тип"
},
"user": {
"create_account": "Създай акаунт",
+25 -4
View File
@@ -318,6 +318,14 @@
"no_addons": "Encara no tens cap complement instal·lat. Vés al directori de complements per començar!"
},
"dialog": {
"hardware": {
"attributes": "Atributs",
"device_path": "Ruta del dispositiu",
"id": "ID",
"search": "Cerca maquinari",
"subsystem": "Subsistema",
"title": "Maquinari"
},
"network": {
"connected_to": "Connectat a {ssid}",
"dhcp": "DHCP",
@@ -382,6 +390,7 @@
"snapshot": {
"addons": "Complements",
"available_snapshots": "Instantànies disponibles",
"confirm_password": "Confirma la contrasenya de la instantània",
"could_not_create": "No s'ha pogut crear la instantània",
"create": "Crea",
"create_blocked_not_running": "Ara mateix no és possible crear una instantània perquè el sistema es troba en estat {state}.",
@@ -409,6 +418,7 @@
"password": "Contrasenya de la instantània",
"password_protected": "protegit amb contrasenya",
"password_protection": "Protecció amb contrasenya",
"passwords_not_matching": "Les contrasenyes no coincideixen",
"security": "Seguretat",
"select_type": "Selecciona què vols restaurar",
"selected": "{number} seleccionada/es",
@@ -548,9 +558,9 @@
},
"counter": {
"actions": {
"decrement": "decreixement",
"increment": "increment",
"reset": "restablir"
"decrement": "decrementa",
"increment": "incrementa",
"reset": "restableix"
}
},
"cover": {
@@ -810,6 +820,7 @@
"was_unplugged": "s'ha desendollat",
"was_unsafe": "és insegur"
},
"retrieval_error": "Error durant l'obtenció de l'entrada del diari de registre",
"show_trace": "Mostra la traça"
},
"media-browser": {
@@ -2954,6 +2965,7 @@
"home_id": "ID principal",
"network": "Xarxa",
"node_id": "ID del node",
"reconfigure_server": "Torna a configurar el servidor",
"remove_node": "Elimina node"
},
"dashboard": {
@@ -3881,10 +3893,19 @@
"intro": "Estàs preparat donar vida pròpia a la teva llar, recuperar la teva privacitat i unir-te a una comunitat mundial de \"tinkerers\"?",
"next": "Següent",
"restore": {
"addons": "Complements",
"confirm_password": "Confirma la contrasenya de la instantània",
"description": "També pots restaurar des d'una instantània anterior.",
"folders": "Carpetes",
"full_snapshot": "Instantània completa",
"hide_log": "Amaga el registre complet",
"in_progress": "Restauració en curs",
"show_log": "Mostra el registre complet"
"partial_snapshot": "Instantània parcial",
"password": "Contrasenya de la instantània",
"password_protection": "Protecció amb contrasenya",
"select_type": "Selecciona què vols restaurar",
"show_log": "Mostra el registre complet",
"type": "Tipus d'instantània"
},
"user": {
"create_account": "Crear compte",
+22 -2
View File
@@ -318,6 +318,14 @@
"no_addons": "Zatím nemáte nainstalované žádné doplňky. Chcete-li začít, přejděte do obchodu s doplňky."
},
"dialog": {
"hardware": {
"attributes": "Atributy",
"device_path": "Cesta k zařízení",
"id": "ID",
"search": "Hledat hardware",
"subsystem": "Subsystém",
"title": "Hardware"
},
"network": {
"connected_to": "Připojeno k {ssid}",
"dhcp": "DHCP",
@@ -382,6 +390,7 @@
"snapshot": {
"addons": "Doplňky",
"available_snapshots": "Dostupné zálohy",
"confirm_password": "Potvrďte heslo zálohy",
"could_not_create": "Nelze vytvořit zálohu",
"create": "Vytvořit",
"create_blocked_not_running": "Vytvoření zálohy není momentálně možné, protože systém je ve \"{state}\".",
@@ -409,6 +418,7 @@
"password": "Heslo zálohy",
"password_protected": "chráněno heslem",
"password_protection": "Ochrana heslem",
"passwords_not_matching": "Hesla se neshodují",
"security": "Zabezpečení",
"select_type": "Vyberte, co chcete obnovit",
"selected": "{number} vybraných",
@@ -810,6 +820,7 @@
"was_unplugged": "bylo odpojeno",
"was_unsafe": "bylo v nebezpečí"
},
"retrieval_error": "Chyba při načítání záznamu",
"show_trace": "Zobrazit trasu"
},
"media-browser": {
@@ -3544,7 +3555,7 @@
"options": "Více možností",
"pick_card": "Kterou kartu chcete přidat?",
"pick_card_view_title": "Kterou kartu byste chtěli přidat do svého {name} pohledu?",
"search_cards": "Vyhledat karty",
"search_cards": "Hledat karty",
"show_code_editor": "Zobrazit editor kódu",
"show_visual_editor": "Zobrazit vizuální editor",
"toggle_editor": "Přepnout Editor",
@@ -3876,10 +3887,19 @@
"intro": "Jste připraveni oživit svůj domov, zachovat si své soukromí a připojit se k celosvětové komunitě tvůrců?",
"next": "Další",
"restore": {
"addons": "Doplňky",
"confirm_password": "Potvrďte heslo zálohy",
"description": "Případně můžete Home Assistant obnovit z poslední zálohy.",
"folders": "Složky",
"full_snapshot": "Úplná záloha",
"hide_log": "Skrýt celý log",
"in_progress": "Probíhá obnovení",
"show_log": "Zobrazit celý log"
"partial_snapshot": "Částečná záloha",
"password": "Heslo zálohy",
"password_protection": "Ochrana heslem",
"select_type": "Vyberte, co chcete obnovit",
"show_log": "Zobrazit celý log",
"type": "Typ zálohy"
},
"user": {
"create_account": "Vytvořit účet",
+29 -2
View File
@@ -318,6 +318,14 @@
"no_addons": "Du har endnu ikke installeret nogen tilføjelsesprogrammer. Gå over til butikken for tilføjelsesprogrammer for at komme i gang!"
},
"dialog": {
"hardware": {
"attributes": "Attributter",
"device_path": "Enhedssti",
"id": "Identifikationsnummer",
"search": "Søg efter hardware",
"subsystem": "Delsystem",
"title": "Hardware"
},
"network": {
"connected_to": "Forbundet til {ssid}",
"dhcp": "DHCP",
@@ -368,6 +376,8 @@
"error": "Der opstod en ukendt fejl",
"error_addon_no_ingress": "Tilføjelsesprogrammet understøtter ikke ingress",
"error_addon_not_found": "Tilføjelsesprogrammet blev ikke fundet",
"error_addon_not_installed": "Tilføjelsesprogrammet er ikke installeret venligst installer det først",
"error_addon_not_started": "Tilføjelsesprogrammet kører ikke venligst start det først",
"faq_link": "Ofte stillede spørgsmål om Home Assistant",
"not_supported": "Denne omdirigering understøttes ikke af din Home Assistant installation. Kontroller {link} for de understøttede omdirigeringer og den version, de blev introduceret i."
},
@@ -380,6 +390,7 @@
"snapshot": {
"addons": "Tilføjelsesprogrammer",
"available_snapshots": "Tilgængelige snapshots",
"confirm_password": "Bekræft snapshot kodeord",
"could_not_create": "Kunne ikke oprette snapshot",
"create": "Opret",
"create_blocked_not_running": "Det er ikke muligt at oprette et snapshot lige nu, fordi systemet er i tilstanden {state}.",
@@ -407,6 +418,7 @@
"password": "Kodeord",
"password_protected": "beskyttet med kodeord",
"password_protection": "Kodeordsbeskyttelse",
"passwords_not_matching": "Kodeordene er forskellige",
"security": "Sikkerhed",
"select_type": "Vælg, hvad der skal gendannes",
"selected": "{number} valgt",
@@ -931,6 +943,9 @@
"config_entry_system_options": {
"enable_new_entities_description": "Hvis deaktiveret, tilføjes nyligt opdagede entiteter fra {integration} ikke automatisk til Home Assistant.",
"enable_new_entities_label": "Aktivér nyligt tilføjede entiteter.",
"enable_polling_description": "Hvis Home Assistant automatisk skal hente {integration} enheder for opdateringer.",
"enable_polling_label": "Aktiver automatisk henting af opdateringer",
"restart_home_assistant": "Du skal genstarte Home Assistant før dine ændringer aktiveres",
"title": "Systemindstillinger for {integration}",
"update": "Opdater"
},
@@ -2073,7 +2088,8 @@
"scripts": "Scripts",
"unknown_error": "Ukendt fejl",
"unnamed_device": "Enhed uden navn",
"update": "Opdater"
"update": "Opdater",
"update_device_error": "Enhedsopdateringen fejlede"
},
"entities": {
"caption": "Entiteter",
@@ -2206,6 +2222,7 @@
"depends_on_cloud": "Afhængig af Cloud tjenester",
"device_unavailable": "Enheden er utilgængelig",
"devices": "{count} {count, plural,\n one {enhed}\n other {enheder}\n}",
"disable_error": "Aktivering eller deaktivering af integrationen fejlede",
"disable_restart_confirm": "Genstart Home Assistant for at fuldføre deaktivering af denne integration",
"disable": {
"disable_confirm": "Er du sikker på, at du vil deaktivere denne integration? Integrationens enheder og entiteter vil blive deaktiveret.",
@@ -2217,6 +2234,7 @@
},
"disabled_cause": "Deaktiveret af {cause}"
},
"disabled_polling": "Den automatiske hentning af opdateret data er frakoblet",
"documentation": "Dokumentation",
"enable_restart_confirm": "Genstart Home Assistant for at fuldføre aktivering af denne integration",
"entities": "{count} {count, plural,\n one {entitet}\n other {entiteter}\n}",
@@ -3873,10 +3891,19 @@
"intro": "Er du klar til at vække dit hjem til live, genvinde dit privatliv og blive medlem af et verdensomspændende fællesskab af tinkerers?",
"next": "Næste",
"restore": {
"addons": "Tilføjelse",
"confirm_password": "Bekræft Snapshot kodeord",
"description": "Alternativt kan du gendanne fra et tidligere snapshot.",
"folders": "Mappe",
"full_snapshot": "Fuld snapshot",
"hide_log": "Skjul fuld log",
"in_progress": "Gendannelse er i gang",
"show_log": "Vis den fulde log"
"partial_snapshot": "Delvis snapshot",
"password": "Snapshot kodeord",
"password_protection": "Kodeordsbeskyttelse",
"select_type": "Vælg det der skal genetableres",
"show_log": "Vis den fulde log",
"type": "Snapshot type"
},
"user": {
"create_account": "Opret konto",

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