mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-22 08:46:35 +00:00
Merge pull request #9483 from home-assistant/dev
This commit is contained in:
commit
7bab245073
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@ -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 }}
|
||||
|
@ -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"),
|
||||
];
|
||||
|
@ -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,
|
||||
@ -78,10 +86,11 @@ 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/ },
|
||||
doneHandler()
|
||||
);
|
||||
webpack(
|
||||
process.env.ES5
|
||||
? bothBuilds(createAppConfig, { isProdBuild: false })
|
||||
: createAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
).watch({ ignored: /build-translations/, poll: isWsl }, doneHandler());
|
||||
gulp.watch(
|
||||
path.join(paths.translations_src, "en.json"),
|
||||
gulp.series("build-translations", "copy-translations-app")
|
||||
@ -137,7 +146,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`
|
||||
|
@ -86,7 +86,7 @@ export class HassioUpdate extends LitElement {
|
||||
"hassio/supervisor/update",
|
||||
`https://github.com//home-assistant/hassio/releases/tag/${this.supervisor.supervisor.version_latest}`
|
||||
)}
|
||||
${this.supervisor.host.features.includes("hassos")
|
||||
${this.supervisor.host.features.includes("haos")
|
||||
? this._renderUpdateCard(
|
||||
"Operating System",
|
||||
"os",
|
||||
|
194
hassio/src/dialogs/hardware/dialog-hassio-hardware.ts
Executable file
194
hassio/src/dialogs/hardware/dialog-hassio-hardware.ts
Executable file
@ -0,0 +1,194 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/common/search/search-input";
|
||||
import { compare } from "../../../../src/common/string/compare";
|
||||
import "../../../../src/components/ha-dialog";
|
||||
import "../../../../src/components/ha-expansion-panel";
|
||||
import { HassioHardwareInfo } from "../../../../src/data/hassio/hardware";
|
||||
import { dump } from "../../../../src/resources/js-yaml-dump";
|
||||
import { haStyle, haStyleDialog } from "../../../../src/resources/styles";
|
||||
import { HomeAssistant } from "../../../../src/types";
|
||||
import { HassioHardwareDialogParams } from "./show-dialog-hassio-hardware";
|
||||
|
||||
const _filterDevices = memoizeOne(
|
||||
(showAdvanced: boolean, hardware: HassioHardwareInfo, filter: string) =>
|
||||
hardware.devices
|
||||
.filter(
|
||||
(device) =>
|
||||
(showAdvanced ||
|
||||
["tty", "gpio", "input"].includes(device.subsystem)) &&
|
||||
(device.by_id?.toLowerCase().includes(filter) ||
|
||||
device.name.toLowerCase().includes(filter) ||
|
||||
device.dev_path.toLocaleLowerCase().includes(filter) ||
|
||||
JSON.stringify(device.attributes)
|
||||
.toLocaleLowerCase()
|
||||
.includes(filter))
|
||||
)
|
||||
.sort((a, b) => compare(a.name, b.name))
|
||||
);
|
||||
|
||||
@customElement("dialog-hassio-hardware")
|
||||
class HassioHardwareDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _dialogParams?: HassioHardwareDialogParams;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
public showDialog(params: HassioHardwareDialogParams) {
|
||||
this._dialogParams = params;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._dialogParams = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._dialogParams) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const devices = _filterDevices(
|
||||
this.hass.userData?.showAdvanced || false,
|
||||
this._dialogParams.hardware,
|
||||
(this._filter || "").toLowerCase()
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
scrimClickAction
|
||||
hideActions
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${true}
|
||||
>
|
||||
<div class="header" slot="heading">
|
||||
<h2>
|
||||
${this._dialogParams.supervisor.localize("dialog.hardware.title")}
|
||||
</h2>
|
||||
<mwc-icon-button dialogAction="close">
|
||||
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
|
||||
</mwc-icon-button>
|
||||
<search-input
|
||||
autofocus
|
||||
no-label-float
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.label=${this._dialogParams.supervisor.localize(
|
||||
"dialog.hardware.search"
|
||||
)}
|
||||
>
|
||||
</search-input>
|
||||
</div>
|
||||
|
||||
${devices.map(
|
||||
(device) =>
|
||||
html`<ha-expansion-panel
|
||||
.header=${device.name}
|
||||
.secondary=${device.by_id || undefined}
|
||||
outlined
|
||||
>
|
||||
<div class="device-property">
|
||||
<span>
|
||||
${this._dialogParams!.supervisor.localize(
|
||||
"dialog.hardware.subsystem"
|
||||
)}:
|
||||
</span>
|
||||
<span>${device.subsystem}</span>
|
||||
</div>
|
||||
<div class="device-property">
|
||||
<span>
|
||||
${this._dialogParams!.supervisor.localize(
|
||||
"dialog.hardware.device_path"
|
||||
)}:
|
||||
</span>
|
||||
<code>${device.dev_path}</code>
|
||||
</div>
|
||||
${device.by_id
|
||||
? html` <div class="device-property">
|
||||
<span>
|
||||
${this._dialogParams!.supervisor.localize(
|
||||
"dialog.hardware.id"
|
||||
)}:
|
||||
</span>
|
||||
<code>${device.by_id}</code>
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="attributes">
|
||||
<span>
|
||||
${this._dialogParams!.supervisor.localize(
|
||||
"dialog.hardware.attributes"
|
||||
)}:
|
||||
</span>
|
||||
<pre>${dump(device.attributes, { indent: 2 })}</pre>
|
||||
</div>
|
||||
</ha-expansion-panel>`
|
||||
)}
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
mwc-icon-button {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
h2 {
|
||||
margin: 18px 42px 0 18px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
margin: 4px 0;
|
||||
}
|
||||
pre,
|
||||
code {
|
||||
background-color: var(--markdown-code-background-color, none);
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
line-height: 1.45;
|
||||
font-family: var(--code-font-family, monospace);
|
||||
}
|
||||
code {
|
||||
font-size: 85%;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
search-input {
|
||||
margin: 0 16px;
|
||||
display: block;
|
||||
}
|
||||
.device-property {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.attributes {
|
||||
margin-top: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-hassio-hardware": HassioHardwareDialog;
|
||||
}
|
||||
}
|
19
hassio/src/dialogs/hardware/show-dialog-hassio-hardware.ts
Normal file
19
hassio/src/dialogs/hardware/show-dialog-hassio-hardware.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { fireEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { HassioHardwareInfo } from "../../../../src/data/hassio/hardware";
|
||||
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
|
||||
|
||||
export interface HassioHardwareDialogParams {
|
||||
supervisor: Supervisor;
|
||||
hardware: HassioHardwareInfo;
|
||||
}
|
||||
|
||||
export const showHassioHardwareDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: HassioHardwareDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-hassio-hardware",
|
||||
dialogImport: () => import("./dialog-hassio-hardware"),
|
||||
dialogParams,
|
||||
});
|
||||
};
|
@ -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";
|
||||
@ -22,6 +22,7 @@ import {
|
||||
import { HassDialog } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { haStyle, haStyleDialog } from "../../../../src/resources/styles";
|
||||
import { HomeAssistant } from "../../../../src/types";
|
||||
import { fileDownload } from "../../../../src/util/file_download";
|
||||
import "../../components/supervisor-snapshot-content";
|
||||
import type { SupervisorSnapshotContent } from "../../components/supervisor-snapshot-content";
|
||||
import { HassioSnapshotDialogParams } from "./show-dialog-hassio-snapshot";
|
||||
@ -66,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>` : ""}
|
||||
@ -86,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>
|
||||
`;
|
||||
}
|
||||
@ -114,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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@ -288,12 +307,11 @@ class HassioSnapshotDialog
|
||||
}
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = signedPath.path;
|
||||
a.download = `home_assistant_snapshot_${slugify(this._computeName)}.tar`;
|
||||
this.shadowRoot!.appendChild(a);
|
||||
a.click();
|
||||
this.shadowRoot!.removeChild(a);
|
||||
fileDownload(
|
||||
this,
|
||||
signedPath.path,
|
||||
`home_assistant_snapshot_${slugify(this._computeName)}.tar`
|
||||
);
|
||||
}
|
||||
|
||||
private get _computeName() {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ import "@material/mwc-button";
|
||||
import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
|
||||
import "@material/mwc-list/mwc-list-item";
|
||||
import { mdiDotsVertical } from "@mdi/js";
|
||||
import { dump } from "js-yaml";
|
||||
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@ -41,8 +40,8 @@ import {
|
||||
roundWithOneDecimal,
|
||||
} from "../../../src/util/calculate";
|
||||
import "../components/supervisor-metric";
|
||||
import { showHassioMarkdownDialog } from "../dialogs/markdown/show-dialog-hassio-markdown";
|
||||
import { showNetworkDialog } from "../dialogs/network/show-dialog-network";
|
||||
import { showHassioHardwareDialog } from "../dialogs/hardware/show-dialog-hassio-hardware";
|
||||
import { hassioStyle } from "../resources/hassio-style";
|
||||
|
||||
@customElement("hassio-host-info")
|
||||
@ -114,7 +113,7 @@ class HassioHostInfo extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</ha-settings-row>
|
||||
${!this.supervisor.host.features.includes("hassos")
|
||||
${!this.supervisor.host.features.includes("haos")
|
||||
? html`<ha-settings-row>
|
||||
<span slot="heading">
|
||||
${this.supervisor.localize("system.host.docker_version")}
|
||||
@ -191,7 +190,7 @@ class HassioHostInfo extends LitElement {
|
||||
<mwc-list-item>
|
||||
${this.supervisor.localize("system.host.hardware")}
|
||||
</mwc-list-item>
|
||||
${this.supervisor.host.features.includes("hassos")
|
||||
${this.supervisor.host.features.includes("haos")
|
||||
? html`<mwc-list-item>
|
||||
${this.supervisor.localize("system.host.import_from_usb")}
|
||||
</mwc-list-item>`
|
||||
@ -229,20 +228,19 @@ class HassioHostInfo extends LitElement {
|
||||
}
|
||||
|
||||
private async _showHardware(): Promise<void> {
|
||||
let hardware;
|
||||
try {
|
||||
const content = await fetchHassioHardwareInfo(this.hass);
|
||||
showHassioMarkdownDialog(this, {
|
||||
title: this.supervisor.localize("system.host.hardware"),
|
||||
content: `<pre>${dump(content, { indent: 2 })}</pre>`,
|
||||
});
|
||||
hardware = await fetchHassioHardwareInfo(this.hass);
|
||||
} catch (err) {
|
||||
showAlertDialog(this, {
|
||||
await showAlertDialog(this, {
|
||||
title: this.supervisor.localize(
|
||||
"system.host.failed_to_get_hardware_list"
|
||||
),
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
showHassioHardwareDialog(this, { supervisor: this.supervisor, hardware });
|
||||
}
|
||||
|
||||
private async _hostReboot(ev: CustomEvent): Promise<void> {
|
||||
|
35
package.json
35
package.json
@ -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",
|
||||
|
2
setup.py
2
setup.py
@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="home-assistant-frontend",
|
||||
version="20210603.0",
|
||||
version="20210630.0",
|
||||
description="The Home Assistant frontend",
|
||||
url="https://github.com/home-assistant/home-assistant-polymer",
|
||||
author="The Home Assistant Authors",
|
||||
|
@ -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
src/auth/ha-password-manager-polyfill.ts
Normal file
110
src/auth/ha-password-manager-polyfill.ts
Normal 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
src/common/color/colors.ts
Normal file
63
src/common/color/colors.ts
Normal 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,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++) {
|
||||
|
@ -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. */
|
||||
|
@ -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, {
|
||||
|
@ -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");
|
||||
|
@ -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
src/common/number/clamp.ts
Normal file
2
src/common/number/clamp.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
@ -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);
|
||||
}
|
||||
`;
|
||||
|
@ -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
src/components/chart/chart-date-adapter.ts
Normal file
197
src/components/chart/chart-date-adapter.ts
Normal 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
src/components/chart/ha-chart-base.ts
Normal file
315
src/components/chart/ha-chart-base.ts
Normal 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;
|
||||
}
|
||||
}
|
392
src/components/chart/state-history-chart-line.ts
Normal file
392
src/components/chart/state-history-chart-line.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
310
src/components/chart/state-history-chart-timeline.ts
Normal file
310
src/components/chart/state-history-chart-timeline.ts
Normal file
@ -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);
|
||||
}
|
||||
`;
|
18
src/components/chart/timeline-chart/const.ts
Normal file
18
src/components/chart/timeline-chart/const.ts
Normal file
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
60
src/components/chart/timeline-chart/textbar-element.ts
Normal file
60
src/components/chart/timeline-chart/textbar-element.ts
Normal file
@ -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 };
|
||||
}
|
||||
}
|
160
src/components/chart/timeline-chart/timeline-controller.ts
Normal file
160
src/components/chart/timeline-chart/timeline-controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
55
src/components/chart/timeline-chart/timeline-scale.ts
Normal file
55
src/components/chart/timeline-chart/timeline-scale.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
@ -14,12 +14,17 @@ class HaExpansionPanel extends LitElement {
|
||||
|
||||
@property() header?: string;
|
||||
|
||||
@property() secondary?: string;
|
||||
|
||||
@query(".container") private _container!: HTMLDivElement;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="summary" @click=${this._toggleContainer}>
|
||||
<slot name="header">${this.header}</slot>
|
||||
<slot class="header" name="header">
|
||||
${this.header}
|
||||
<slot class="secondary" name="secondary">${this.secondary}</slot>
|
||||
</slot>
|
||||
<ha-svg-icon
|
||||
.path=${mdiChevronDown}
|
||||
class="summary-icon ${classMap({ expanded: this.expanded })}"
|
||||
@ -106,6 +111,16 @@ class HaExpansionPanel extends LitElement {
|
||||
.container.expanded {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
display: block;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@ -7,14 +7,14 @@ import { afterNextRender } from "../common/util/render-status";
|
||||
import { FrontendLocaleData } from "../data/translation";
|
||||
import { getValueInPercentage, normalize } from "../util/calculate";
|
||||
|
||||
// Workaround for https://github.com/home-assistant/frontend/issues/6467
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
const getAngle = (value: number, min: number, max: number) => {
|
||||
const percentage = getValueInPercentage(normalize(value, min, max), min, max);
|
||||
return (percentage * 180) / 100;
|
||||
};
|
||||
|
||||
// Workaround for https://github.com/home-assistant/frontend/issues/6467
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
@customElement("ha-gauge")
|
||||
export class Gauge extends LitElement {
|
||||
@property({ type: Number }) public min = 0;
|
||||
|
@ -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
src/components/map/ha-entity-marker.ts
Normal file
69
src/components/map/ha-entity-marker.ts
Normal 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);
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@ -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
|
||||
);
|
@ -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");
|
||||
|
||||
|
@ -14,12 +14,17 @@ interface HassioHardwareAudioList {
|
||||
};
|
||||
}
|
||||
|
||||
interface HardwareDevice {
|
||||
attributes: Record<string, string>;
|
||||
by_id: null | string;
|
||||
dev_path: string;
|
||||
name: string;
|
||||
subsystem: string;
|
||||
sysfs: string;
|
||||
}
|
||||
|
||||
export interface HassioHardwareInfo {
|
||||
serial: string[];
|
||||
input: string[];
|
||||
disk: string[];
|
||||
gpio: string[];
|
||||
audio: Record<string, unknown>;
|
||||
devices: HardwareDevice[];
|
||||
}
|
||||
|
||||
export const fetchHassioHardwareAudio = async (
|
||||
|
@ -41,6 +41,7 @@ export interface HassioSnapshotDetail extends HassioSnapshot {
|
||||
export interface HassioFullSnapshotCreateParams {
|
||||
name: string;
|
||||
password?: string;
|
||||
confirm_password?: string;
|
||||
}
|
||||
export interface HassioPartialSnapshotCreateParams
|
||||
extends HassioFullSnapshotCreateParams {
|
||||
|
@ -8,6 +8,7 @@ export enum LightColorModes {
|
||||
ONOFF = "onoff",
|
||||
BRIGHTNESS = "brightness",
|
||||
COLOR_TEMP = "color_temp",
|
||||
WHITE = "white",
|
||||
HS = "hs",
|
||||
XY = "xy",
|
||||
RGB = "rgb",
|
||||
|
25
src/data/select.ts
Normal file
25
src/data/select.ts
Normal 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 }
|
||||
);
|
@ -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;
|
||||
|
@ -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 ||
|
||||
|
@ -23,16 +23,12 @@ class MoreInfoPerson extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="id,user_id,editable"
|
||||
></ha-attributes>
|
||||
${this.stateObj.attributes.latitude && this.stateObj.attributes.longitude
|
||||
? html`
|
||||
<ha-map
|
||||
.hass=${this.hass}
|
||||
.entities=${this._entityArray(this.stateObj.entity_id)}
|
||||
autoFit
|
||||
></ha-map>
|
||||
`
|
||||
: ""}
|
||||
@ -51,6 +47,11 @@ class MoreInfoPerson extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="id,user_id,editable"
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -17,11 +17,6 @@ class MoreInfoTimer extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="remaining"
|
||||
></ha-attributes>
|
||||
<div class="actions">
|
||||
${this.stateObj.state === "idle" || this.stateObj.state === "paused"
|
||||
? html`
|
||||
@ -57,6 +52,11 @@ class MoreInfoTimer extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="remaining"
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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() {
|
||||
|
@ -20,4 +20,5 @@
|
||||
"content" in document.createElement("template"))) {
|
||||
document.write("<script src='/static/polyfills/webcomponents-bundle.js'><"+"/script>");
|
||||
}
|
||||
var isS11_12 = /.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent);
|
||||
</script>
|
||||
|
@ -43,13 +43,16 @@
|
||||
<%= renderTemplate('_preload_roboto') %>
|
||||
|
||||
<script crossorigin="use-credentials">
|
||||
import("<%= latestPageJS %>");
|
||||
window.latestJS = true;
|
||||
window.providersPromise = fetch("/auth/providers", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!window.globalThis) {
|
||||
window.globalThis = window;
|
||||
// Safari 12 and below does not have a compliant ES2015 implementation of template literals, so we ship ES5
|
||||
if (!isS11_12) {
|
||||
import("<%= latestPageJS %>");
|
||||
window.latestJS = true;
|
||||
window.providersPromise = fetch("/auth/providers", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!window.globalThis) {
|
||||
window.globalThis = window;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -67,12 +67,15 @@
|
||||
<%= renderTemplate('_preload_roboto') %>
|
||||
|
||||
<script <% if (!useWDS) { %>crossorigin="use-credentials"<% } %>>
|
||||
import("<%= latestCoreJS %>");
|
||||
import("<%= latestAppJS %>");
|
||||
window.customPanelJS = "<%= latestCustomPanelJS %>";
|
||||
window.latestJS = true;
|
||||
if (!window.globalThis) {
|
||||
window.globalThis = window;
|
||||
// Safari 12 and below does not have a compliant ES2015 implementation of template literals, so we ship ES5
|
||||
if (!isS11_12) {
|
||||
import("<%= latestCoreJS %>");
|
||||
import("<%= latestAppJS %>");
|
||||
window.customPanelJS = "<%= latestCustomPanelJS %>";
|
||||
window.latestJS = true;
|
||||
if (!window.globalThis) {
|
||||
window.globalThis = window;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
|
@ -75,13 +75,16 @@
|
||||
<%= renderTemplate('_preload_roboto') %>
|
||||
|
||||
<script crossorigin="use-credentials">
|
||||
import("<%= latestPageJS %>");
|
||||
window.latestJS = true;
|
||||
window.stepsPromise = fetch("/api/onboarding", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!window.globalThis) {
|
||||
window.globalThis = window;
|
||||
// Safari 12 and below does not have a compliant ES2015 implementation of template literals, so we ship ES5
|
||||
if (!isS11_12) {
|
||||
import("<%= latestPageJS %>");
|
||||
window.latestJS = true;
|
||||
window.stepsPromise = fetch("/api/onboarding", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!window.globalThis) {
|
||||
window.globalThis = window;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -43,7 +43,7 @@ const COMPONENTS = {
|
||||
class PartialPanelResolver extends HassRouterPage {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public narrow?: boolean;
|
||||
@property({ type: Boolean }) public narrow?: boolean;
|
||||
|
||||
private _waitForStart = false;
|
||||
|
||||
@ -206,7 +206,7 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
this._currentPage &&
|
||||
!this.hass.panels[this._currentPage]
|
||||
) {
|
||||
if (this.hass.config.state !== STATE_NOT_RUNNING) {
|
||||
if (this.hass.config.state === STATE_NOT_RUNNING) {
|
||||
this._waitForStart = true;
|
||||
if (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
|
@ -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>`
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
@ -24,10 +29,13 @@ import {
|
||||
import "../../../../../layouts/hass-tabs-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
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 {
|
||||
@ -41,6 +49,8 @@ class ZWaveJSConfigDashboard extends LitElement {
|
||||
|
||||
@property() public configEntryId?: string;
|
||||
|
||||
@state() private _configEntry?: ConfigEntry;
|
||||
|
||||
@state() private _network?: ZWaveJSNetwork;
|
||||
|
||||
@state() private _nodes?: ZWaveJSNode[];
|
||||
@ -58,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}
|
||||
@ -161,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>
|
||||
@ -208,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),
|
||||
@ -253,6 +354,12 @@ class ZWaveJSConfigDashboard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async _healNetworkClicked() {
|
||||
showZWaveJSHealNetworkDialog(this, {
|
||||
entry_id: this.configEntryId!,
|
||||
});
|
||||
}
|
||||
|
||||
private _dataCollectionToggled(ev) {
|
||||
setDataCollectionPreference(
|
||||
this.hass!,
|
||||
@ -261,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();
|
||||
|
||||
@ -312,12 +430,7 @@ class ZWaveJSConfigDashboard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = signedPath.path;
|
||||
a.download = `zwave_js_dump.jsonl`;
|
||||
this.shadowRoot!.appendChild(a);
|
||||
a.click();
|
||||
this.shadowRoot!.removeChild(a);
|
||||
fileDownload(this, signedPath.path, `zwave_js_dump.jsonl`);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@ -337,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) }
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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)}"
|
||||
|
186
src/panels/lovelace/entity-rows/hui-select-entity-row.ts
Normal file
186
src/panels/lovelace/entity-rows/hui-select-entity-row.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -127,6 +127,10 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
public willUpdate(changedProperties: PropertyValues) {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
if (this.lovelace?.editMode) {
|
||||
import("./default-view-editable");
|
||||
}
|
||||
|
||||
if (changedProperties.has("hass")) {
|
||||
const oldHass = changedProperties.get("hass") as
|
||||
| HomeAssistant
|
||||
@ -140,14 +144,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
if (changedProperties.has("narrow")) {
|
||||
this._updateColumns();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (this.lovelace?.editMode) {
|
||||
import("./default-view-editable");
|
||||
return;
|
||||
}
|
||||
|
||||
const oldLovelace = changedProperties.get("lovelace") as
|
||||
@ -155,10 +152,11 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
| undefined;
|
||||
|
||||
if (
|
||||
changedProperties.has("lovelace") &&
|
||||
oldLovelace &&
|
||||
(oldLovelace.config !== this.lovelace?.config ||
|
||||
oldLovelace.editMode !== this.lovelace?.editMode)
|
||||
changedProperties.has("cards") ||
|
||||
(changedProperties.has("lovelace") &&
|
||||
oldLovelace &&
|
||||
(oldLovelace.config !== this.lovelace!.config ||
|
||||
oldLovelace.editMode !== this.lovelace!.editMode))
|
||||
) {
|
||||
this._createColumns();
|
||||
}
|
||||
|
@ -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);
|
@ -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
src/panels/map/ha-panel-map.ts
Normal file
103
src/panels/map/ha-panel-map.ts
Normal 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;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user