Merge pull request #5369 from home-assistant/dev

20200330.0
This commit is contained in:
Bram Kragten 2020-03-30 21:34:24 +02:00 committed by GitHub
commit 3be4b9d79b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
168 changed files with 5879 additions and 2739 deletions

View File

@ -8,7 +8,7 @@ trigger:
pr: none pr: none
variables: variables:
- name: versionWheels - name: versionWheels
value: '1.3-3.7-alpine3.10' value: '1.10.1-3.7-alpine3.11'
- name: versionNode - name: versionNode
value: '12.1' value: '12.1'
- group: twine - group: twine

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -34,6 +34,7 @@ export const createMediaPlayerEntities = () => [
media_content_type: "movie", media_content_type: "movie",
media_title: "Epic sax guy 10 hours", media_title: "Epic sax guy 10 hours",
app_name: "YouTube", app_name: "YouTube",
entity_picture: "/images/frenck.jpg",
supported_features: 33, supported_features: 33,
}), }),
getEntity("media_player", "living_room", "playing", { getEntity("media_player", "living_room", "playing", {
@ -42,6 +43,7 @@ export const createMediaPlayerEntities = () => [
media_title: "Chapter 1", media_title: "Chapter 1",
media_series_title: "House of Cards", media_series_title: "House of Cards",
app_name: "Netflix", app_name: "Netflix",
entity_picture: "/images/netflix.jpg",
supported_features: 1, supported_features: 1,
}), }),
getEntity("media_player", "sonos_idle", "idle", { getEntity("media_player", "sonos_idle", "idle", {

View File

@ -93,7 +93,7 @@ class HassioAddonRepositoryEl extends LitElement {
? "not_available" ? "not_available"
: ""} : ""}
.iconImage=${atLeastVersion( .iconImage=${atLeastVersion(
this.hass.connection.haVersion, this.hass.config.version,
0, 0,
105 105
) && addon.icon ) && addon.icon

View File

@ -107,7 +107,7 @@ class HassioAddonInfo extends LitElement {
<hassio-card-content <hassio-card-content
.hass=${this.hass} .hass=${this.hass}
.title="${this.addon.name} ${this.addon .title="${this.addon.name} ${this.addon
.last_version} is available" .version_latest} is available"
.description="You are currently running version ${this.addon .description="You are currently running version ${this.addon
.version}" .version}"
icon="hassio:arrow-up-bold-circle" icon="hassio:arrow-up-bold-circle"
@ -179,7 +179,7 @@ class HassioAddonInfo extends LitElement {
`} `}
` `
: html` : html`
${this.addon.last_version} ${this.addon.version_latest}
`} `}
</div> </div>
</div> </div>
@ -636,7 +636,7 @@ class HassioAddonInfo extends LitElement {
this.addon && this.addon &&
!this.addon.detached && !this.addon.detached &&
this.addon.version && this.addon.version &&
this.addon.version !== this.addon.last_version this.addon.version !== this.addon.version_latest
); );
} }
@ -661,8 +661,7 @@ class HassioAddonInfo extends LitElement {
private get _computeCannotIngressSidebar(): boolean { private get _computeCannotIngressSidebar(): boolean {
return ( return (
!this.addon.ingress || !this.addon.ingress || !atLeastVersion(this.hass.config.version, 0, 92)
!atLeastVersion(this.hass.connection.haVersion, 0, 92)
); );
} }

View File

@ -67,7 +67,7 @@ class HassioAddons extends LitElement {
? "running" ? "running"
: "stopped"} : "stopped"}
.iconImage=${atLeastVersion( .iconImage=${atLeastVersion(
this.hass.connection.haVersion, this.hass.config.version,
0, 0,
105 105
) && addon.icon ) && addon.icon

View File

@ -40,8 +40,8 @@ export class HassioUpdate extends LitElement {
].filter((value) => { ].filter((value) => {
return ( return (
!!value && !!value &&
(value.last_version (value.version_latest
? value.version !== value.last_version ? value.version !== value.version_latest
: value.version_latest : value.version_latest
? value.version !== value.version_latest ? value.version !== value.version_latest
: false) : false)
@ -68,26 +68,26 @@ export class HassioUpdate extends LitElement {
${this._renderUpdateCard( ${this._renderUpdateCard(
"Home Assistant Core", "Home Assistant Core",
this.hassInfo.version, this.hassInfo.version,
this.hassInfo.last_version, this.hassInfo.version_latest,
"hassio/homeassistant/update", "hassio/homeassistant/update",
`https://${ `https://${
this.hassInfo.last_version.includes("b") ? "rc" : "www" this.hassInfo.version_latest.includes("b") ? "rc" : "www"
}.home-assistant.io/latest-release-notes/`, }.home-assistant.io/latest-release-notes/`,
"hassio:home-assistant" "hassio:home-assistant"
)} )}
${this._renderUpdateCard( ${this._renderUpdateCard(
"Supervisor", "Supervisor",
this.supervisorInfo.version, this.supervisorInfo.version,
this.supervisorInfo.last_version, this.supervisorInfo.version_latest,
"hassio/supervisor/update", "hassio/supervisor/update",
`https://github.com//home-assistant/hassio/releases/tag/${this.supervisorInfo.last_version}` `https://github.com//home-assistant/hassio/releases/tag/${this.supervisorInfo.version_latest}`
)} )}
${this.hassOsInfo ${this.hassOsInfo
? this._renderUpdateCard( ? this._renderUpdateCard(
"Operating System", "Operating System",
this.hassOsInfo.version, this.hassOsInfo.version,
this.hassOsInfo.version_latest, this.hassOsInfo.version_latest,
"hassio/hassos/update", "hassio/os/update",
`https://github.com//home-assistant/hassos/releases/tag/${this.hassOsInfo.version_latest}` `https://github.com//home-assistant/hassos/releases/tag/${this.hassOsInfo.version_latest}`
) )
: ""} : ""}

View File

@ -87,8 +87,7 @@ class HassioMain extends ProvideHassLitMixin(HassRouterPage) {
applyThemesOnElement( applyThemesOnElement(
this.parentElement, this.parentElement,
this.hass.themes, this.hass.themes,
this.hass.selectedTheme, this.hass.selectedTheme || this.hass.themes.default_theme
true
); );
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev)); this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
// Paulus - March 17, 2019 // Paulus - March 17, 2019

View File

@ -100,7 +100,7 @@ class HassioHostInfo extends LitElement {
<ha-call-api-button <ha-call-api-button
class="warning" class="warning"
.hass=${this.hass} .hass=${this.hass}
path="hassio/hassos/config/sync" path="hassio/os/config/sync"
title="Load HassOS configs or updates from USB" title="Load HassOS configs or updates from USB"
>Import from USB</ha-call-api-button >Import from USB</ha-call-api-button
> >
@ -108,9 +108,7 @@ class HassioHostInfo extends LitElement {
: ""} : ""}
${this.hostInfo.version !== this.hostInfo.version_latest ${this.hostInfo.version !== this.hostInfo.version_latest
? html` ? html`
<ha-call-api-button <ha-call-api-button .hass=${this.hass} path="hassio/os/update"
.hass=${this.hass}
path="hassio/hassos/update"
>Update</ha-call-api-button >Update</ha-call-api-button
> >
` `

View File

@ -41,7 +41,7 @@ class HassioSupervisorInfo extends LitElement {
</tr> </tr>
<tr> <tr>
<td>Latest version</td> <td>Latest version</td>
<td>${this.supervisorInfo.last_version}</td> <td>${this.supervisorInfo.version_latest}</td>
</tr> </tr>
${this.supervisorInfo.channel !== "stable" ${this.supervisorInfo.channel !== "stable"
? html` ? html`
@ -63,7 +63,7 @@ class HassioSupervisorInfo extends LitElement {
<ha-call-api-button .hass=${this.hass} path="hassio/supervisor/reload" <ha-call-api-button .hass=${this.hass} path="hassio/supervisor/reload"
>Reload</ha-call-api-button >Reload</ha-call-api-button
> >
${this.supervisorInfo.version !== this.supervisorInfo.last_version ${this.supervisorInfo.version !== this.supervisorInfo.version_latest
? html` ? html`
<ha-call-api-button <ha-call-api-button
.hass=${this.hass} .hass=${this.hass}

View File

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="home-assistant-frontend", name="home-assistant-frontend",
version="20200318.1", version="20200330.0",
description="The Home Assistant frontend", description="The Home Assistant frontend",
url="https://github.com/home-assistant/home-assistant-polymer", url="https://github.com/home-assistant/home-assistant-polymer",
author="The Home Assistant Authors", author="The Home Assistant Authors",

View File

@ -1,4 +1,10 @@
import { derivedStyles } from "../../resources/styles"; import { derivedStyles } from "../../resources/styles";
import { HomeAssistant, Theme } from "../../types";
interface ProcessedTheme {
keys: { [key: string]: "" };
styles: { [key: string]: string };
}
const hexToRgb = (hex: string): string | null => { const hexToRgb = (hex: string): string | null => {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
@ -15,67 +21,82 @@ const hexToRgb = (hex: string): string | null => {
: null; : null;
}; };
let PROCESSED_THEMES: { [key: string]: ProcessedTheme } = {};
/** /**
* Apply a theme to an element by setting the CSS variables on it. * Apply a theme to an element by setting the CSS variables on it.
* *
* element: Element to apply theme on. * element: Element to apply theme on.
* themes: HASS Theme information * themes: HASS Theme information
* localTheme: selected theme. * selectedTheme: selected theme.
* updateMeta: boolean if we should update the theme-color meta element.
*/ */
export const applyThemesOnElement = ( export const applyThemesOnElement = (
element, element,
themes, themes: HomeAssistant["themes"],
localTheme, selectedTheme?: string
updateMeta = false
) => { ) => {
if (!element._themes) { const newTheme = selectedTheme
element._themes = {}; ? PROCESSED_THEMES[selectedTheme] || processTheme(selectedTheme, themes)
} : undefined;
let themeName = themes.default_theme;
if (localTheme === "default" || (localTheme && themes.themes[localTheme])) {
themeName = localTheme;
}
const styles = { ...element._themes };
if (themeName !== "default") {
const theme = { ...derivedStyles, ...themes.themes[themeName] };
Object.keys(theme).forEach((key) => {
const prefixedKey = `--${key}`;
element._themes[prefixedKey] = "";
styles[prefixedKey] = theme[key];
if (key.startsWith("rgb")) {
return;
}
const rgbKey = `rgb-${key}`;
if (theme[rgbKey] !== undefined) {
return;
}
const prefixedRgbKey = `--${rgbKey}`;
element._themes[prefixedRgbKey] = "";
const rgbValue = hexToRgb(theme[key]);
if (rgbValue !== null) {
styles[prefixedRgbKey] = rgbValue;
}
});
}
if (element.updateStyles) {
element.updateStyles(styles);
} else if (window.ShadyCSS) {
// implement updateStyles() method of Polymer elements
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
}
if (!updateMeta) { if (!element._themes && !newTheme) {
// No styles to reset, and no styles to set
return; return;
} }
const meta = document.querySelector("meta[name=theme-color]"); // Add previous set keys to reset them, and new theme
if (meta) { const styles = { ...element._themes, ...newTheme?.styles };
if (!meta.hasAttribute("default-content")) { element._themes = newTheme?.keys;
meta.setAttribute("default-content", meta.getAttribute("content")!);
} // Set and/or reset styles
const themeColor = if (element.updateStyles) {
styles["--primary-color"] || meta.getAttribute("default-content"); element.updateStyles(styles);
meta.setAttribute("content", themeColor); } else if (window.ShadyCSS) {
// Implement updateStyles() method of Polymer elements
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
} }
}; };
const processTheme = (
themeName: string,
themes: HomeAssistant["themes"]
): ProcessedTheme | undefined => {
if (!themes.themes[themeName]) {
return;
}
const theme: Theme = {
...derivedStyles,
...themes.themes[themeName],
};
const styles = {};
const keys = {};
for (const key of Object.keys(theme)) {
const prefixedKey = `--${key}`;
const value = theme[key];
styles[prefixedKey] = value;
keys[prefixedKey] = "";
// Try to create a rgb value for this key if it is a hex color
if (!value.startsWith("#")) {
// Not a hex color
continue;
}
const rgbKey = `rgb-${key}`;
if (theme[rgbKey] !== undefined) {
// Theme has it's own rgb value
continue;
}
const rgbValue = hexToRgb(value);
if (rgbValue !== null) {
const prefixedRgbKey = `--${rgbKey}`;
styles[prefixedRgbKey] = rgbValue;
keys[prefixedRgbKey] = "";
}
}
PROCESSED_THEMES[themeName] = { styles, keys };
return { styles, keys };
};
export const invalidateThemeCache = () => {
PROCESSED_THEMES = {};
};

View File

@ -4,17 +4,58 @@ import { domainIcon } from "./domain_icon";
export const coverIcon = (state: HassEntity): string => { export const coverIcon = (state: HassEntity): string => {
const open = state.state !== "closed"; const open = state.state !== "closed";
switch (state.attributes.device_class) { switch (state.attributes.device_class) {
case "garage": case "garage":
return open ? "hass:garage-open" : "hass:garage"; switch (state.state) {
case "opening":
return "hass:arrow-up-box";
case "closing":
return "hass:arrow-down-box";
case "closed":
return "hass:garage";
default:
return "hass:garage-open";
}
case "gate":
switch (state.state) {
case "opening":
case "closing":
return "hass:gate-arrow-right";
case "closed":
return "hass:gate";
default:
return "hass:gate-open";
}
case "door": case "door":
return open ? "hass:door-open" : "hass:door-closed"; return open ? "hass:door-open" : "hass:door-closed";
case "damper":
return open ? "hass:circle" : "hass:circle-slice-8";
case "shutter": case "shutter":
return open ? "hass:window-shutter-open" : "hass:window-shutter"; return open ? "hass:window-shutter-open" : "hass:window-shutter";
case "blind": case "blind":
return open ? "hass:blinds-open" : "hass:blinds"; case "curtain":
switch (state.state) {
case "opening":
return "hass:arrow-up-box";
case "closing":
return "hass:arrow-down-box";
case "closed":
return "hass:blinds";
default:
return "hass:blinds-open";
}
case "window": case "window":
return open ? "hass:window-open" : "hass:window-closed"; switch (state.state) {
case "opening":
return "hass:arrow-up-box";
case "closing":
return "hass:arrow-down-box";
case "closed":
return "hass:window-closed";
default:
return "hass:window-open";
}
default: default:
return domainIcon("cover", state.state); return domainIcon("cover", state.state);
} }

View File

@ -77,7 +77,16 @@ export const domainIcon = (domain: string, state?: string): string => {
: "hass:checkbox-marked-circle"; : "hass:checkbox-marked-circle";
case "cover": case "cover":
return state === "closed" ? "hass:window-closed" : "hass:window-open"; switch (state) {
case "opening":
return "hass:arrow-up-box";
case "closing":
return "hass:arrow-down-box";
case "closed":
return "hass:window-closed";
default:
return "hass:window-open";
}
case "lock": case "lock":
return state && state === "unlocked" ? "hass:lock-open" : "hass:lock"; return state && state === "unlocked" ? "hass:lock-open" : "hass:lock";

View File

@ -69,9 +69,10 @@ export interface DataTableSortColumnData {
export interface DataTableColumnData extends DataTableSortColumnData { export interface DataTableColumnData extends DataTableSortColumnData {
title: string; title: string;
type?: "numeric" | "icon"; type?: "numeric" | "icon" | "icon-button";
template?: <T>(data: any, row: T) => TemplateResult | string; template?: <T>(data: any, row: T) => TemplateResult | string;
width?: string; width?: string;
maxWidth?: string;
grows?: boolean; grows?: boolean;
} }
@ -227,10 +228,13 @@ export class HaDataTable extends LitElement {
const sorted = key === this._sortColumn; const sorted = key === this._sortColumn;
const classes = { const classes = {
"mdc-data-table__header-cell--numeric": Boolean( "mdc-data-table__header-cell--numeric": Boolean(
column.type && column.type === "numeric" column.type === "numeric"
), ),
"mdc-data-table__header-cell--icon": Boolean( "mdc-data-table__header-cell--icon": Boolean(
column.type && column.type === "icon" column.type === "icon"
),
"mdc-data-table__header-cell--icon-button": Boolean(
column.type === "icon-button"
), ),
sortable: Boolean(column.sortable), sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted), "not-sorted": Boolean(column.sortable && !sorted),
@ -241,9 +245,8 @@ export class HaDataTable extends LitElement {
class="mdc-data-table__header-cell ${classMap(classes)}" class="mdc-data-table__header-cell ${classMap(classes)}"
style=${column.width style=${column.width
? styleMap({ ? styleMap({
[column.grows ? "minWidth" : "width"]: String( [column.grows ? "minWidth" : "width"]: column.width,
column.width maxWidth: column.maxWidth || "",
),
}) })
: ""} : ""}
role="columnheader" role="columnheader"
@ -318,10 +321,13 @@ export class HaDataTable extends LitElement {
<div <div
class="mdc-data-table__cell ${classMap({ class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--numeric": Boolean( "mdc-data-table__cell--numeric": Boolean(
column.type && column.type === "numeric" column.type === "numeric"
), ),
"mdc-data-table__cell--icon": Boolean( "mdc-data-table__cell--icon": Boolean(
column.type && column.type === "icon" column.type === "icon"
),
"mdc-data-table__cell--icon-button": Boolean(
column.type === "icon-button"
), ),
grows: Boolean(column.grows), grows: Boolean(column.grows),
})}" })}"
@ -329,7 +335,10 @@ export class HaDataTable extends LitElement {
? styleMap({ ? styleMap({
[column.grows [column.grows
? "minWidth" ? "minWidth"
: "width"]: String(column.width), : "width"]: column.width,
maxWidth: column.maxWidth
? column.maxWidth
: "",
}) })
: ""} : ""}
> >
@ -532,6 +541,7 @@ export class HaDataTable extends LitElement {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
.mdc-data-table__cell.mdc-data-table__cell--icon { .mdc-data-table__cell.mdc-data-table__cell--icon {
@ -544,7 +554,7 @@ export class HaDataTable extends LitElement {
padding-left: 16px; padding-left: 16px;
/* @noflip */ /* @noflip */
padding-right: 0; padding-right: 0;
width: 40px; width: 56px;
} }
[dir="rtl"] .mdc-data-table__header-cell--checkbox, [dir="rtl"] .mdc-data-table__header-cell--checkbox,
.mdc-data-table__header-cell--checkbox[dir="rtl"], .mdc-data-table__header-cell--checkbox[dir="rtl"],
@ -591,7 +601,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--icon, .mdc-data-table__header-cell--icon,
.mdc-data-table__cell--icon { .mdc-data-table__cell--icon {
width: 24px; width: 54px;
} }
.mdc-data-table__header-cell.mdc-data-table__header-cell--icon { .mdc-data-table__header-cell.mdc-data-table__header-cell--icon {
@ -610,6 +620,28 @@ export class HaDataTable extends LitElement {
margin-right: -8px; margin-right: -8px;
} }
.mdc-data-table__header-cell--icon-button,
.mdc-data-table__cell--icon-button {
width: 56px;
padding: 8px;
}
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
width: 64px;
padding-left: 16px;
}
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
width: 64px;
padding-right: 16px;
}
.mdc-data-table__cell--icon-button a {
color: var(--primary-text-color);
}
.mdc-data-table__header-cell { .mdc-data-table__header-cell {
font-family: Roboto, sans-serif; font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@ -695,6 +727,9 @@ export class HaDataTable extends LitElement {
.center { .center {
text-align: center; text-align: center;
} }
.secondary {
color: var(--secondary-text-color);
}
.scroller { .scroller {
display: flex; display: flex;
position: relative; position: relative;

View File

@ -246,7 +246,7 @@ class HaEntityPicker extends LitElement {
paper-input > paper-icon-button { paper-input > paper-icon-button {
width: 24px; width: 24px;
height: 24px; height: 24px;
padding: 2px; padding: 0px 2px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
[hidden] { [hidden] {

View File

@ -19,7 +19,7 @@ class HaCoverControls extends PolymerElement {
<div class="state"> <div class="state">
<paper-icon-button <paper-icon-button
aria-label="Open cover" aria-label="Open cover"
icon="hass:arrow-up" icon="[[computeOpenIcon(stateObj)]]"
on-click="onOpenTap" on-click="onOpenTap"
invisible$="[[!entityObj.supportsOpen]]" invisible$="[[!entityObj.supportsOpen]]"
disabled="[[computeOpenDisabled(stateObj, entityObj)]]" disabled="[[computeOpenDisabled(stateObj, entityObj)]]"
@ -32,7 +32,7 @@ class HaCoverControls extends PolymerElement {
></paper-icon-button> ></paper-icon-button>
<paper-icon-button <paper-icon-button
aria-label="Close cover" aria-label="Close cover"
icon="hass:arrow-down" icon="[[computeCloseIcon(stateObj)]]"
on-click="onCloseTap" on-click="onCloseTap"
invisible$="[[!entityObj.supportsClose]]" invisible$="[[!entityObj.supportsClose]]"
disabled="[[computeClosedDisabled(stateObj, entityObj)]]" disabled="[[computeClosedDisabled(stateObj, entityObj)]]"
@ -60,6 +60,26 @@ class HaCoverControls extends PolymerElement {
return new CoverEntity(hass, stateObj); return new CoverEntity(hass, stateObj);
} }
computeOpenIcon(stateObj) {
switch (stateObj.attributes.device_class) {
case "awning":
case "gate":
return "hass:arrow-expand-horizontal";
default:
return "hass:arrow-up";
}
}
computeCloseIcon(stateObj) {
switch (stateObj.attributes.device_class) {
case "awning":
case "gate":
return "hass:arrow-collapse-horizontal";
default:
return "hass:arrow-down";
}
}
computeOpenDisabled(stateObj, entityObj) { computeOpenDisabled(stateObj, entityObj) {
var assumedState = stateObj.attributes.assumed_state === true; var assumedState = stateObj.attributes.assumed_state === true;
return (entityObj.isFullyOpen || entityObj.isOpening) && !assumedState; return (entityObj.isFullyOpen || entityObj.isOpening) && !assumedState;

View File

@ -32,7 +32,7 @@ import { classMap } from "lit-html/directives/class-map";
import { PaperIconItemElement } from "@polymer/paper-item/paper-icon-item"; import { PaperIconItemElement } from "@polymer/paper-item/paper-icon-item";
import { computeRTL } from "../common/util/compute_rtl"; import { computeRTL } from "../common/util/compute_rtl";
import { compare } from "../common/string/compare"; import { compare } from "../common/string/compare";
import { getDefaultPanelUrlPath, getDefaultPanel } from "../data/panel"; import { getDefaultPanel } from "../data/panel";
const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"]; const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"];
@ -87,10 +87,8 @@ const computePanels = (hass: HomeAssistant): [PanelInfo[], PanelInfo[]] => {
const beforeSpacer: PanelInfo[] = []; const beforeSpacer: PanelInfo[] = [];
const afterSpacer: PanelInfo[] = []; const afterSpacer: PanelInfo[] = [];
const defaultPage = getDefaultPanelUrlPath();
Object.values(panels).forEach((panel) => { Object.values(panels).forEach((panel) => {
if (!panel.title || panel.url_path === defaultPage) { if (!panel.title || panel.url_path === hass.defaultPanel) {
return; return;
} }
(SHOW_AFTER_SPACER.includes(panel.url_path) (SHOW_AFTER_SPACER.includes(panel.url_path)
@ -143,7 +141,7 @@ class HaSidebar extends LitElement {
} }
} }
const defaultPanel = getDefaultPanel(hass.panels); const defaultPanel = getDefaultPanel(hass);
return html` return html`
<div class="menu"> <div class="menu">
@ -297,7 +295,8 @@ class HaSidebar extends LitElement {
hass.panelUrl !== oldHass.panelUrl || hass.panelUrl !== oldHass.panelUrl ||
hass.user !== oldHass.user || hass.user !== oldHass.user ||
hass.localize !== oldHass.localize || hass.localize !== oldHass.localize ||
hass.states !== oldHass.states hass.states !== oldHass.states ||
hass.defaultPanel !== oldHass.defaultPanel
); );
} }
@ -530,6 +529,7 @@ class HaSidebar extends LitElement {
overflow-x: hidden; overflow-x: hidden;
scrollbar-color: var(--scrollbar-thumb-color) transparent; scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin; scrollbar-width: thin;
background: none;
} }
a { a {

View File

@ -12,9 +12,9 @@ export const callAlarmAction = (
| "arm_night" | "arm_night"
| "arm_custom_bypass" | "arm_custom_bypass"
| "disarm", | "disarm",
code: string code?: string
) => { ) => {
hass!.callService("alarm_control_panel", "alarm_" + action, { hass!.callService("alarm_control_panel", `alarm_${action}`, {
entity_id: entity, entity_id: entity,
code, code,
}); });

View File

@ -25,3 +25,16 @@ export const fetchAuthProviders = () =>
fetch("/auth/providers", { fetch("/auth/providers", {
credentials: "same-origin", credentials: "same-origin",
}); });
export const createAuthForUser = async (
hass: HomeAssistant,
userId: string,
username: string,
password: string
) =>
hass.callWS({
type: "config/auth_provider/homeassistant/create",
user_id: userId,
username,
password,
});

View File

@ -53,6 +53,9 @@ export const fallbackDeviceName = (
return undefined; return undefined;
}; };
export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
devices.filter((device) => device.area_id === areaId);
export const updateDeviceRegistryEntry = ( export const updateDeviceRegistryEntry = (
hass: HomeAssistant, hass: HomeAssistant,
deviceId: string, deviceId: string,

View File

@ -27,6 +27,16 @@ export interface EntityRegistryEntryUpdateParams {
new_entity_id?: string; new_entity_id?: string;
} }
export const findBatteryEntity = (
hass: HomeAssistant,
entities: EntityRegistryEntry[]
): EntityRegistryEntry | undefined =>
entities.find(
(entity) =>
hass.states[entity.entity_id] &&
hass.states[entity.entity_id].attributes.device_class === "battery"
);
export const computeEntityRegistryName = ( export const computeEntityRegistryName = (
hass: HomeAssistant, hass: HomeAssistant,
entry: EntityRegistryEntry entry: EntityRegistryEntry

1
src/data/graph.ts Normal file
View File

@ -0,0 +1 @@
export const strokeWidth = 5;

View File

@ -29,7 +29,7 @@ export interface HassioAddonDetails extends HassioAddonInfo {
arch: "armhf" | "aarch64" | "i386" | "amd64"; arch: "armhf" | "aarch64" | "i386" | "amd64";
machine: any; machine: any;
homeassistant: string; homeassistant: string;
last_version: string; version_latest: string;
boot: "auto" | "manual"; boot: "auto" | "manual";
build: boolean; build: boolean;
options: object; options: object;

View File

@ -23,7 +23,7 @@ export const fetchHassioHassOsInfo = async (hass: HomeAssistant) => {
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHassOSInfo>>( await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET", "GET",
"hassio/hassos/info" "hassio/os/info"
) )
); );
}; };

View File

@ -23,7 +23,7 @@ export const fetchHassioHomeAssistantInfo = async (hass: HomeAssistant) => {
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>( await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"GET", "GET",
"hassio/homeassistant/info" "hassio/core/info"
) )
); );
}; };

View File

@ -17,3 +17,12 @@ export const subscribeMQTTTopic = (
topic, topic,
}); });
}; };
export const removeMQTTDeviceEntry = (
hass: HomeAssistant,
deviceId: string
): Promise<void> =>
hass.callWS({
type: "mqtt/device/remove",
device_id: deviceId,
});

View File

@ -1,13 +1,20 @@
import { HomeAssistant, PanelInfo } from "../types"; import { HomeAssistant, PanelInfo } from "../types";
import { fireEvent } from "../common/dom/fire_event";
/** Panel to show when no panel is picked. */ /** Panel to show when no panel is picked. */
const DEFAULT_PANEL = "lovelace"; export const DEFAULT_PANEL = "lovelace";
export const getDefaultPanelUrlPath = () => export const getStorageDefaultPanelUrlPath = () =>
localStorage.defaultPage || DEFAULT_PANEL; localStorage.defaultPanel
? JSON.parse(localStorage.defaultPanel)
: DEFAULT_PANEL;
export const getDefaultPanel = (panels: HomeAssistant["panels"]) => export const setDefaultPanel = (element: HTMLElement, urlPath: string) => {
panels[localStorage.defaultPage] || panels[DEFAULT_PANEL]; fireEvent(element, "hass-default-panel", { defaultPanel: urlPath });
};
export const getDefaultPanel = (hass: HomeAssistant) =>
hass.panels[hass.defaultPanel];
export const getPanelTitle = (hass: HomeAssistant): string | undefined => { export const getPanelTitle = (hass: HomeAssistant): string | undefined => {
if (!hass.panels) { if (!hass.panels) {
@ -22,14 +29,13 @@ export const getPanelTitle = (hass: HomeAssistant): string | undefined => {
return; return;
} }
if (panel.url_path === "lovelace") {
return hass.localize("panel.states");
}
if (panel.url_path === "profile") { if (panel.url_path === "profile") {
return hass.localize("panel.profile"); return hass.localize("panel.profile");
} }
return ( return hass.localize(`panel.${panel.title}`) || panel.title || undefined;
hass.localize(`panel.${panel.title}`) ||
panel.title ||
// default panel
(hass.panels[localStorage.defaultPage] || hass.panels[DEFAULT_PANEL]).title!
);
}; };

View File

@ -5,6 +5,8 @@ export const SYSTEM_GROUP_ID_ADMIN = "system-admin";
export const SYSTEM_GROUP_ID_USER = "system-users"; export const SYSTEM_GROUP_ID_USER = "system-users";
export const SYSTEM_GROUP_ID_READ_ONLY = "system-read-only"; export const SYSTEM_GROUP_ID_READ_ONLY = "system-read-only";
export const GROUPS = [SYSTEM_GROUP_ID_USER, SYSTEM_GROUP_ID_ADMIN];
export interface User { export interface User {
id: string; id: string;
name: string; name: string;
@ -15,7 +17,7 @@ export interface User {
credentials: Credential[]; credentials: Credential[];
} }
interface UpdateUserParams { export interface UpdateUserParams {
name?: User["name"]; name?: User["name"];
group_ids?: User["group_ids"]; group_ids?: User["group_ids"];
} }
@ -25,10 +27,16 @@ export const fetchUsers = async (hass: HomeAssistant) =>
type: "config/auth/list", type: "config/auth/list",
}); });
export const createUser = async (hass: HomeAssistant, name: string) => export const createUser = async (
hass: HomeAssistant,
name: string,
// tslint:disable-next-line: variable-name
group_ids?: User["group_ids"]
) =>
hass.callWS<{ user: User }>({ hass.callWS<{ user: User }>({
type: "config/auth/create", type: "config/auth/create",
name, name,
group_ids,
}); });
export const updateUser = async ( export const updateUser = async (

View File

@ -130,7 +130,11 @@ class DataEntryFlowDialog extends LitElement {
> >
${this._loading || (this._step === null && this._handlers === undefined) ${this._loading || (this._step === null && this._handlers === undefined)
? html` ? html`
<step-flow-loading></step-flow-loading> <step-flow-loading
.label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.loading_first_time"
)}
></step-flow-loading>
` `
: this._step === undefined : this._step === undefined
? // When we are going to next step, we render 1 round of empty ? // When we are going to next step, we render 1 round of empty

View File

@ -5,14 +5,22 @@ import {
css, css,
customElement, customElement,
CSSResult, CSSResult,
property,
} from "lit-element"; } from "lit-element";
import "@polymer/paper-spinner/paper-spinner-lite"; import "@polymer/paper-spinner/paper-spinner-lite";
@customElement("step-flow-loading") @customElement("step-flow-loading")
class StepFlowLoading extends LitElement { class StepFlowLoading extends LitElement {
@property() public label?: string;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div class="init-spinner"> <div class="init-spinner">
${this.label
? html`
<div>${this.label}</div>
`
: ""}
<paper-spinner-lite active></paper-spinner-lite> <paper-spinner-lite active></paper-spinner-lite>
</div> </div>
`; `;

View File

@ -142,6 +142,9 @@ class DialogBox extends LitElement {
min-width: initial; min-width: initial;
} }
} }
a {
color: var(--primary-color);
}
p { p {
margin: 0; margin: 0;
padding-top: 6px; padding-top: 6px;

View File

@ -1,8 +1,9 @@
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { TemplateResult } from "lit-html";
interface BaseDialogParams { interface BaseDialogParams {
confirmText?: string; confirmText?: string;
text?: string; text?: string | TemplateResult;
title?: string; title?: string;
} }

View File

@ -92,12 +92,12 @@ window.hassConnection.then(({ conn }) => {
subscribeFrontendUserData(conn, "core", noop); subscribeFrontendUserData(conn, "core", noop);
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) { if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
(window as WindowWithLovelaceProm).llConfProm = fetchConfig( const llWindow = window as WindowWithLovelaceProm;
conn, llWindow.llConfProm = fetchConfig(conn, null, false);
null, llWindow.llConfProm.catch(() => {
false // Ignore it, it is handled by Lovelace panel.
); });
(window as WindowWithLovelaceProm).llResProm = fetchResources(conn); llWindow.llResProm = fetchResources(conn);
} }
}); });

View File

@ -11,7 +11,7 @@ export const demoConfig: HassConfig = {
temperature: "°C", temperature: "°C",
volume: "L", volume: "L",
}, },
components: ["notify.html5", "history"], components: ["notify.html5", "history", "shopping_list"],
time_zone: "America/Los_Angeles", time_zone: "America/Los_Angeles",
config_dir: "/config", config_dir: "/config",
version: "DEMO", version: "DEMO",

View File

@ -1,4 +1,7 @@
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element"; import {
applyThemesOnElement,
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { demoConfig } from "./demo_config"; import { demoConfig } from "./demo_config";
import { demoServices } from "./demo_services"; import { demoServices } from "./demo_services";
@ -8,6 +11,7 @@ import { HomeAssistant } from "../types";
import { HassEntities } from "home-assistant-js-websocket"; import { HassEntities } from "home-assistant-js-websocket";
import { getLocalLanguage } from "../util/hass-translation"; import { getLocalLanguage } from "../util/hass-translation";
import { translationMetadata } from "../resources/translations-metadata"; import { translationMetadata } from "../resources/translations-metadata";
import { DEFAULT_PANEL } from "../data/panel";
const ensureArray = <T>(val: T | T[]): T[] => const ensureArray = <T>(val: T | T[]): T[] =>
Array.isArray(val) ? val : [val]; Array.isArray(val) ? val : [val];
@ -169,6 +173,7 @@ export const provideHass = (
name: "Demo User", name: "Demo User",
}, },
panelUrl: "lovelace", panelUrl: "lovelace",
defaultPanel: DEFAULT_PANEL,
language: localLanguage, language: localLanguage,
selectedLanguage: localLanguage, selectedLanguage: localLanguage,
@ -224,6 +229,7 @@ export const provideHass = (
(eventListeners[event] || []).forEach((fn) => fn(event)); (eventListeners[event] || []).forEach((fn) => fn(event));
}, },
mockTheme(theme) { mockTheme(theme) {
invalidateThemeCache();
hass().updateHass({ hass().updateHass({
selectedTheme: theme ? "mock" : "default", selectedTheme: theme ? "mock" : "default",
themes: { themes: {
@ -237,8 +243,7 @@ export const provideHass = (
applyThemesOnElement( applyThemesOnElement(
document.documentElement, document.documentElement,
themes, themes,
selectedTheme, selectedTheme as string
true
); );
}, },

View File

@ -88,6 +88,7 @@ export class HaTabsSubpageDataTable extends LitElement {
.route=${this.route} .route=${this.route}
.tabs=${this.tabs} .tabs=${this.tabs}
> >
<div slot="toolbar-icon"><slot name="toolbar-icon"></slot></div>
${this.narrow ${this.narrow
? html` ? html`
<div slot="header"> <div slot="header">

View File

@ -10,7 +10,7 @@ import { registerServiceWorker } from "../util/register-service-worker";
import { Route, HomeAssistant } from "../types"; import { Route, HomeAssistant } from "../types";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import { HassElement } from "../state/hass-element"; import { HassElement } from "../state/hass-element";
import { getDefaultPanelUrlPath } from "../data/panel"; import { getStorageDefaultPanelUrlPath } from "../data/panel";
export class HomeAssistantAppEl extends HassElement { export class HomeAssistantAppEl extends HassElement {
@property() private _route?: Route; @property() private _route?: Route;
@ -86,7 +86,7 @@ export class HomeAssistantAppEl extends HassElement {
this._route === undefined && this._route === undefined &&
(route.path === "" || route.path === "/") (route.path === "" || route.path === "/")
) { ) {
navigate(window, `/${getDefaultPanelUrlPath()}`, true); navigate(window, `/${getStorageDefaultPanelUrlPath()}`, true);
return; return;
} }

View File

@ -109,9 +109,9 @@ class DialogAreaDetail extends LitElement {
name: this._name.trim(), name: this._name.trim(),
}; };
if (this._params!.entry) { if (this._params!.entry) {
await this._params!.updateEntry(values); await this._params!.updateEntry!(values);
} else { } else {
await this._params!.createEntry(values); await this._params!.createEntry!(values);
} }
this._params = undefined; this._params = undefined;
} catch (err) { } catch (err) {
@ -124,7 +124,7 @@ class DialogAreaDetail extends LitElement {
private async _deleteEntry() { private async _deleteEntry() {
this._submitting = true; this._submitting = true;
try { try {
if (await this._params!.removeEntry()) { if (await this._params!.removeEntry!()) {
this._params = undefined; this._params = undefined;
} }
} finally { } finally {

View File

@ -0,0 +1,397 @@
import "@material/mwc-button";
import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable";
import "@polymer/paper-input/paper-input";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../../components/dialog/ha-paper-dialog";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import memoizeOne from "memoize-one";
import {
AreaRegistryEntry,
updateAreaRegistryEntry,
deleteAreaRegistryEntry,
} from "../../../data/area_registry";
import {
DeviceRegistryEntry,
devicesInArea,
computeDeviceName,
} from "../../../data/device_registry";
import { configSections } from "../ha-panel-config";
import {
showAreaRegistryDetailDialog,
loadAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { RelatedResult, findRelated } from "../../../data/search";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { ifDefined } from "lit-html/directives/if-defined";
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
@property() public hass!: HomeAssistant;
@property() public areaId!: string;
@property() public areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[];
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean;
@property() public showAdvanced!: boolean;
@property() public route!: Route;
@property() private _related?: RelatedResult;
private _area = memoizeOne((areaId: string, areas: AreaRegistryEntry[]):
| AreaRegistryEntry
| undefined => areas.find((area) => area.area_id === areaId));
private _devices = memoizeOne(
(areaId: string, devices: DeviceRegistryEntry[]): DeviceRegistryEntry[] =>
devicesInArea(devices, areaId)
);
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("areaId")) {
this._findRelated();
}
}
protected render(): TemplateResult {
const area = this._area(this.areaId, this.areas);
if (!area) {
return html`
<hass-error-screen
error="${this.hass.localize("ui.panel.config.areas.area_not_found")}"
></hass-error-screen>
`;
}
const devices = this._devices(this.areaId, this.devices);
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.tabs=${configSections.integrations}
.route=${this.route}
>
${this.narrow
? html`
<span slot="header">
${area.name}
</span>
`
: ""}
<paper-icon-button
slot="toolbar-icon"
icon="hass:settings"
.entry=${area}
@click=${this._showSettings}
></paper-icon-button>
<div class="container">
${!this.narrow
? html`
<div class="fullwidth">
<h1>${area.name}</h1>
</div>
`
: ""}
<div class="column">
<ha-card
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? devices.map(
(device) =>
html`
<a href="/config/devices/device/${device.id}">
<paper-item>
<paper-item-body>
${computeDeviceName(device, this.hass)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
`
)
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.no_devices"
)}</paper-item
>
`}
</ha-card>
</div>
<div class="column">
${isComponentLoaded(this.hass, "automation")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations"
)}
>${this._related?.automation?.length
? this._related.automation.map((automation) => {
const state = this.hass.states[automation];
return state
? html`
<div>
<a
href=${ifDefined(
state.attributes.id
? `/config/automation/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item
.disabled=${!state.attributes.id}
>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
${!state.attributes.id
? html`
<paper-tooltip
>${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</paper-tooltip>
`
: ""}
</div>
`
: "";
})
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}</paper-item
>
`}
</ha-card>
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass, "scene")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes"
)}
>${this._related?.scene?.length
? this._related.scene.map((scene) => {
const state = this.hass.states[scene];
return state
? html`
<div>
<a
href=${ifDefined(
state.attributes.id
? `/config/scene/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item
.disabled=${!state.attributes.id}
>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
${!state.attributes.id
? html`
<paper-tooltip
>${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</paper-tooltip>
`
: ""}
</div>
`
: "";
})
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}</paper-item
>
`}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass, "script")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts"
)}
>${this._related?.script?.length
? this._related.script.map((script) => {
const state = this.hass.states[script];
return state
? html`
<a
href=${ifDefined(
state.attributes.id
? `/config/script/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
`
: "";
})
: html`
<paper-item class="no-link">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}</paper-item
>
`}
</ha-card>
`
: ""}
</div>
</div>
</hass-tabs-subpage>
`;
}
private async _findRelated() {
this._related = await findRelated(this.hass, "area", this.areaId);
}
private _showSettings(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
updateEntry: async (values) =>
updateAreaRegistryEntry(this.hass!, entry!.area_id, values),
removeEntry: async () => {
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_title"
),
text: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_text"
),
dismissText: this.hass.localize("ui.common.no"),
confirmText: this.hass.localize("ui.common.yes"),
}))
) {
return false;
}
try {
await deleteAreaRegistryEntry(this.hass!, entry!.area_id);
return true;
} catch (err) {
return false;
}
},
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
h1 {
margin-top: 0;
font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var(
--paper-font-headline_-_-webkit-font-smoothing
);
font-size: var(--paper-font-headline_-_font-size);
font-weight: var(--paper-font-headline_-_font-weight);
letter-spacing: var(--paper-font-headline_-_letter-spacing);
line-height: var(--paper-font-headline_-_line-height);
opacity: var(--dark-primary-opacity);
}
.container {
display: flex;
flex-wrap: wrap;
margin: auto;
max-width: 1000px;
margin-top: 32px;
margin-bottom: 32px;
}
.column {
padding: 8px;
box-sizing: border-box;
width: 33%;
flex-grow: 1;
}
.fullwidth {
padding: 8px;
width: 100%;
}
.column > *:not(:first-child) {
margin-top: 16px;
}
:host([narrow]) .column {
width: 100%;
}
:host([narrow]) .container {
margin-top: 0;
}
paper-item {
cursor: pointer;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
paper-item.no-link {
cursor: default;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-area-page": HaConfigAreaPage;
}
}

View File

@ -0,0 +1,200 @@
import {
LitElement,
TemplateResult,
html,
css,
CSSResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { HomeAssistant, Route } from "../../../types";
import {
AreaRegistryEntry,
createAreaRegistryEntry,
} from "../../../data/area_registry";
import "../../../components/ha-fab";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
import "../ha-config-section";
import {
showAreaRegistryDetailDialog,
loadAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { configSections } from "../ha-panel-config";
import memoizeOne from "memoize-one";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import {
devicesInArea,
DeviceRegistryEntry,
} from "../../../data/device_registry";
import { navigate } from "../../../common/navigate";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide?: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() public areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[];
private _areas = memoizeOne(
(areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[]) => {
return areas.map((area) => {
return {
...area,
devices: devicesInArea(devices, area.area_id).length,
};
});
}
);
private _columns = memoizeOne(
(narrow: boolean): DataTableColumnContainer =>
narrow
? {
name: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.area"
),
sortable: true,
filterable: true,
grows: true,
direction: "asc",
},
}
: {
name: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.area"
),
sortable: true,
filterable: true,
grows: true,
direction: "asc",
},
devices: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.devices"
),
sortable: true,
type: "numeric",
width: "20%",
direction: "asc",
},
}
);
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.tabs=${configSections.integrations}
.route=${this.route}
.columns=${this._columns(this.narrow)}
.data=${this._areas(this.areas, this.devices)}
@row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize(
"ui.panel.config.areas.picker.no_areas"
)}
id="area_id"
>
<paper-icon-button
slot="toolbar-icon"
icon="hass:help-circle"
@click=${this._showHelp}
></paper-icon-button>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.areas.picker.create_area"
)}"
@click=${this._createArea}
></ha-fab>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
private _createArea() {
this._openDialog();
}
private _showHelp() {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.areas.caption"),
text: html`
${this.hass.localize("ui.panel.config.areas.picker.introduction")}
<p>
${this.hass.localize("ui.panel.config.areas.picker.introduction2")}
</p>
<a href="/config/integrations/dashboard">
${this.hass.localize(
"ui.panel.config.areas.picker.integrations_page"
)}
</a>
`,
});
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const areaId = ev.detail.id;
navigate(this, `/config/areas/area/${areaId}`);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
createEntry: async (values) =>
createAreaRegistryEntry(this.hass!, values),
});
}
static get styles(): CSSResult {
return css`
hass-loading-screen {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab.rtl {
right: auto;
left: 16px;
}
ha-fab[is-wide].rtl {
bottom: 24px;
right: auto;
left: 24px;
}
`;
}
}

View File

@ -1,226 +1,120 @@
import "./ha-config-areas-dashboard";
import "./ha-config-area-page";
import { compare } from "../../../common/string/compare";
import { import {
LitElement,
TemplateResult,
html,
css,
CSSResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { HomeAssistant, Route } from "../../../types";
import {
AreaRegistryEntry,
updateAreaRegistryEntry,
deleteAreaRegistryEntry,
createAreaRegistryEntry,
subscribeAreaRegistry, subscribeAreaRegistry,
AreaRegistryEntry,
} from "../../../data/area_registry"; } from "../../../data/area_registry";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../layouts/hass-tabs-subpage";
import "../../../layouts/hass-loading-screen";
import "../ha-config-section";
import { import {
showAreaRegistryDetailDialog, HassRouterPage,
loadAreaRegistryDetailDialog, RouterOptions,
} from "./show-dialog-area-registry-detail"; } from "../../../layouts/hass-router-page";
import { classMap } from "lit-html/directives/class-map"; import { property, customElement, PropertyValues } from "lit-element";
import { computeRTL } from "../../../common/util/compute_rtl"; import { HomeAssistant } from "../../../types";
import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { configSections } from "../ha-panel-config";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-config-areas") @customElement("ha-config-areas")
export class HaConfigAreas extends LitElement { class HaConfigAreas extends HassRouterPage {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@property() public isWide?: boolean;
@property() public narrow!: boolean; @property() public narrow!: boolean;
@property() public route!: Route; @property() public isWide!: boolean;
@property() private _areas?: AreaRegistryEntry[]; @property() public showAdvanced!: boolean;
private _unsubAreas?: UnsubscribeFunc;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
dashboard: {
tag: "ha-config-areas-dashboard",
cache: true,
},
area: {
tag: "ha-config-area-page",
},
},
};
@property() private _configEntries: ConfigEntry[] = [];
@property() private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@property() private _areas: AreaRegistryEntry[] = [];
private _unsubs?: UnsubscribeFunc[];
public connectedCallback() {
super.connectedCallback();
if (!this.hass) {
return;
}
this._loadData();
}
public disconnectedCallback() { public disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (this._unsubAreas) { if (this._unsubs) {
this._unsubAreas(); while (this._unsubs.length) {
this._unsubs.pop()!();
}
this._unsubs = undefined;
} }
} }
protected render(): TemplateResult {
if (!this.hass || this._areas === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.integrations}
>
<ha-config-section .isWide=${this.isWide}>
<span slot="header">
${this.hass.localize("ui.panel.config.areas.picker.header")}
</span>
<span slot="introduction">
${this.hass.localize("ui.panel.config.areas.picker.introduction")}
<p>
${this.hass.localize(
"ui.panel.config.areas.picker.introduction2"
)}
</p>
<a href="/config/integrations/dashboard">
${this.hass.localize(
"ui.panel.config.areas.picker.integrations_page"
)}
</a>
</span>
<ha-card>
${this._areas.map((entry) => {
return html`
<paper-item @click=${this._openEditEntry} .entry=${entry}>
<paper-item-body>
${entry.name}
</paper-item-body>
</paper-item>
`;
})}
${this._areas.length === 0
? html`
<div class="empty">
${this.hass.localize("ui.panel.config.areas.no_areas")}
<mwc-button @click=${this._createArea}>
${this.hass.localize("ui.panel.config.areas.create_area")}
</mwc-button>
</div>
`
: html``}
</ha-card>
</ha-config-section>
</hass-tabs-subpage>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize("ui.panel.config.areas.create_area")}"
@click=${this._createArea}
class="${classMap({
rtl: computeRTL(this.hass),
})}"
></ha-fab>
`;
}
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog(); this.addEventListener("hass-reload-entries", () => {
} this._loadData();
protected updated(changedProps) {
super.updated(changedProps);
if (!this._unsubAreas) {
this._unsubAreas = subscribeAreaRegistry(
this.hass.connection,
(areas) => {
this._areas = areas;
}
);
}
}
private _createArea() {
this._openDialog();
}
private _openEditEntry(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
createEntry: async (values) =>
createAreaRegistryEntry(this.hass!, values),
updateEntry: async (values) =>
updateAreaRegistryEntry(this.hass!, entry!.area_id, values),
removeEntry: async () => {
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_title"
),
text: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_text"
),
dismissText: this.hass.localize("ui.common.no"),
confirmText: this.hass.localize("ui.common.yes"),
}))
) {
return false;
}
try {
await deleteAreaRegistryEntry(this.hass!, entry!.area_id);
return true;
} catch (err) {
return false;
}
},
}); });
} }
static get styles(): CSSResult { protected updated(changedProps: PropertyValues) {
return css` super.updated(changedProps);
hass-loading-screen { if (!this._unsubs && changedProps.has("hass")) {
--app-header-background-color: var(--sidebar-background-color); this._loadData();
--app-header-text-color: var(--sidebar-text-color); }
} }
a {
color: var(--primary-color);
}
ha-card {
max-width: 600px;
margin: 16px auto;
overflow: hidden;
}
.empty {
text-align: center;
}
paper-item {
cursor: pointer;
padding-top: 4px;
padding-bottom: 4px;
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] { protected updatePageEl(pageEl) {
bottom: 24px; pageEl.hass = this.hass;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab.rtl {
right: auto;
left: 16px;
}
ha-fab[is-wide].rtl { if (this._currentPage === "area") {
bottom: 24px; pageEl.areaId = this.routeTail.path.substr(1);
right: auto; }
left: 24px;
} pageEl.entries = this._configEntries;
`; pageEl.devices = this._deviceRegistryEntries;
pageEl.areas = this._areas;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail;
}
private _loadData() {
getConfigEntries(this.hass).then((configEntries) => {
this._configEntries = configEntries.sort((conf1, conf2) =>
compare(conf1.title, conf2.title)
);
});
if (this._unsubs) {
return;
}
this._unsubs = [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._deviceRegistryEntries = entries;
}),
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-areas": HaConfigAreas;
} }
} }

View File

@ -6,11 +6,11 @@ import {
export interface AreaRegistryDetailDialogParams { export interface AreaRegistryDetailDialogParams {
entry?: AreaRegistryEntry; entry?: AreaRegistryEntry;
createEntry: (values: AreaRegistryEntryMutableParams) => Promise<unknown>; createEntry?: (values: AreaRegistryEntryMutableParams) => Promise<unknown>;
updateEntry: ( updateEntry?: (
updates: Partial<AreaRegistryEntryMutableParams> updates: Partial<AreaRegistryEntryMutableParams>
) => Promise<unknown>; ) => Promise<unknown>;
removeEntry: () => Promise<boolean>; removeEntry?: () => Promise<boolean>;
} }
export const loadAreaRegistryDetailDialog = () => export const loadAreaRegistryDetailDialog = () =>

View File

@ -7,18 +7,13 @@ import {
property, property,
customElement, customElement,
} from "lit-element"; } from "lit-element";
import { ifDefined } from "lit-html/directives/if-defined";
import "@polymer/paper-icon-button/paper-icon-button"; import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-tooltip/paper-tooltip"; import "@polymer/paper-tooltip/paper-tooltip";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage-data-table";
import "../../../components/ha-card";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/entity/ha-entity-toggle"; import "../../../components/entity/ha-entity-toggle";
import "../ha-config-section";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeRTL } from "../../../common/util/compute_rtl"; import { computeRTL } from "../../../common/util/compute_rtl";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
@ -27,12 +22,16 @@ import {
AutomationEntity, AutomationEntity,
showAutomationEditor, showAutomationEditor,
AutomationConfig, AutomationConfig,
triggerAutomation,
} from "../../../data/automation"; } from "../../../data/automation";
import { formatDateTime } from "../../../common/datetime/format_date_time"; import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { showThingtalkDialog } from "./show-dialog-thingtalk"; import { showThingtalkDialog } from "./show-dialog-thingtalk";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import memoizeOne from "memoize-one";
import { ifDefined } from "lit-html/directives/if-defined";
@customElement("ha-automation-picker") @customElement("ha-automation-picker")
class HaAutomationPicker extends LitElement { class HaAutomationPicker extends LitElement {
@ -42,139 +41,152 @@ class HaAutomationPicker extends LitElement {
@property() public route!: Route; @property() public route!: Route;
@property() public automations!: AutomationEntity[]; @property() public automations!: AutomationEntity[];
private _automations = memoizeOne((automations: AutomationEntity[]) => {
return automations.map((automation) => {
return {
...automation,
name: computeStateName(automation),
};
});
});
private _columns = memoizeOne(
(narrow: boolean, _language): DataTableColumnContainer => {
const columns: DataTableColumnContainer = {
toggle: {
title: "",
type: "icon",
template: (_toggle, automation) =>
html`
<ha-entity-toggle
.hass=${this.hass}
.stateObj=${automation}
></ha-entity-toggle>
`,
},
name: {
title: this.hass.localize(
"ui.panel.config.automation.picker.headers.name"
),
sortable: true,
filterable: true,
direction: "asc",
grows: true,
template: (name, automation: any) => html`
${name}
<div class="secondary">
${this.hass.localize("ui.card.automation.last_triggered")}:
${automation.attributes.last_triggered
? formatDateTime(
new Date(automation.attributes.last_triggered),
this.hass.language
)
: this.hass.localize("ui.components.relative_time.never")}
</div>
`,
},
};
if (!narrow) {
columns.execute = {
title: "",
template: (_info, automation) => html`
<mwc-button .automation=${automation} @click=${this._execute}>
${this.hass.localize("ui.card.automation.trigger")}
</mwc-button>
`,
};
}
columns.info = {
title: "",
type: "icon-button",
template: (_info, automation) => html`
<paper-icon-button
.automation=${automation}
@click=${this._showInfo}
icon="hass:information-outline"
title="${this.hass.localize(
"ui.panel.config.automation.picker.show_info_automation"
)}"
></paper-icon-button>
`,
};
columns.edit = {
title: "",
type: "icon-button",
template: (_info, automation: any) => html`
<a
href=${ifDefined(
automation.attributes.id
? `/config/automation/edit/${automation.attributes.id}`
: undefined
)}
>
<paper-icon-button
.icon=${automation.attributes.id
? "hass:pencil"
: "hass:pencil-off"}
.disabled=${!automation.attributes.id}
title="${this.hass.localize(
"ui.panel.config.automation.picker.show_info_automation"
)}"
></paper-icon-button>
</a>
${!automation.attributes.id
? html`
<paper-tooltip position="left">
${this.hass.localize(
"ui.panel.config.automation.picker.only_editable"
)}
</paper-tooltip>
`
: ""}
`,
};
return columns;
}
);
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
back-path="/config" back-path="/config"
.route=${this.route} .route=${this.route}
.tabs=${configSections.automation} .tabs=${configSections.automation}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._automations(this.automations)}
id="entity_id"
.noDataText=${this.hass.localize(
"ui.panel.config.automation.picker.no_automations"
)}
> >
<ha-config-section .isWide=${this.isWide}> </hass-tabs-subpage-data-table>
<div slot="header"> <ha-fab
${this.hass.localize("ui.panel.config.automation.picker.header")} slot="fab"
</div> ?is-wide=${this.isWide}
<div slot="introduction"> ?narrow=${this.narrow}
${this.hass.localize( icon="hass:plus"
"ui.panel.config.automation.picker.introduction" title=${this.hass.localize(
)} "ui.panel.config.automation.picker.add_automation"
<p> )}
<a ?rtl=${computeRTL(this.hass)}
href="https://home-assistant.io/docs/automation/editor/" @click=${this._createNew}
target="_blank" ></ha-fab>
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.automation.picker.learn_more"
)}
</a>
</p>
</div>
<ha-card
.heading=${this.hass.localize(
"ui.panel.config.automation.picker.pick_automation"
)}
>
${this.automations.length === 0
? html`
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.automation.picker.no_automations"
)}
</p>
</div>
`
: this.automations.map(
(automation) => html`
<div class='automation'>
<ha-entity-toggle
.hass=${this.hass}
.stateObj=${automation}
></ha-entity-toggle>
<paper-item-body two-line>
<div>${computeStateName(automation)}</div>
<div secondary>
${this.hass.localize(
"ui.card.automation.last_triggered"
)}: ${
automation.attributes.last_triggered
? formatDateTime(
new Date(automation.attributes.last_triggered),
this.hass.language
)
: this.hass.localize("ui.components.relative_time.never")
}
</div>
</paper-item-body>
<div class='actions'>
<paper-icon-button
.automation=${automation}
@click=${this._showInfo}
icon="hass:information-outline"
title="${this.hass.localize(
"ui.panel.config.automation.picker.show_info_automation"
)}"
></paper-icon-button>
<a
href=${ifDefined(
automation.attributes.id
? `/config/automation/edit/${automation.attributes.id}`
: undefined
)}
>
<paper-icon-button
title="${this.hass.localize(
"ui.panel.config.automation.picker.edit_automation"
)}"
icon="hass:pencil"
.disabled=${!automation.attributes.id}
></paper-icon-button>
${
!automation.attributes.id
? html`
<paper-tooltip position="left">
${this.hass.localize(
"ui.panel.config.automation.picker.only_editable"
)}
</paper-tooltip>
`
: ""
}
</a>
</div>
</div>
</a>
`
)}
</ha-card>
</ha-config-section>
<div>
<ha-fab
slot="fab"
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title=${this.hass.localize(
"ui.panel.config.automation.picker.add_automation"
)}
?rtl=${computeRTL(this.hass)}
@click=${this._createNew}
></ha-fab>
</div>
</hass-tabs-subpage>
`; `;
} }
private _showInfo(ev) { private _showInfo(ev) {
ev.stopPropagation();
const entityId = ev.currentTarget.automation.entity_id; const entityId = ev.currentTarget.automation.entity_id;
fireEvent(this, "hass-more-info", { entityId }); fireEvent(this, "hass-more-info", { entityId });
} }
private _execute(ev) {
const entityId = ev.currentTarget.automation.entity_id;
triggerAutomation(this.hass, entityId);
}
private _createNew() { private _createNew() {
if (!isComponentLoaded(this.hass, "cloud")) { if (!isComponentLoaded(this.hass, "cloud")) {
showAutomationEditor(this); showAutomationEditor(this);
@ -190,33 +202,6 @@ class HaAutomationPicker extends LitElement {
return [ return [
haStyle, haStyle,
css` css`
:host {
display: block;
}
ha-card {
margin-bottom: 56px;
}
.automation {
display: flex;
flex-direction: horizontal;
align-items: center;
padding: 0 8px 0 16px;
}
.automation a[href] {
color: var(--primary-text-color);
}
ha-entity-toggle {
margin-right: 16px;
}
.actions {
display: flex;
}
ha-fab { ha-fab {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
@ -242,10 +227,6 @@ class HaAutomationPicker extends LitElement {
right: auto; right: auto;
left: 24px; left: 24px;
} }
a {
color: var(--primary-color);
}
`, `,
]; ];
} }

View File

@ -0,0 +1,43 @@
import { DeviceRegistryEntry } from "../../../../data/device_registry";
import { removeMQTTDeviceEntry } from "../../../../data/mqtt";
import {
LitElement,
html,
customElement,
property,
TemplateResult,
CSSResult,
} from "lit-element";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../../../../types";
import { haStyle } from "../../../../resources/styles";
@customElement("ha-device-card-mqtt")
export class HaDeviceCardMqtt extends LitElement {
@property() public hass!: HomeAssistant;
@property() public device!: DeviceRegistryEntry;
protected render(): TemplateResult {
return html`
<mwc-button class="warning" @click="${this._confirmDeleteEntry}">
${this.hass.localize("ui.panel.config.devices.delete")}
</mwc-button>
`;
}
private async _confirmDeleteEntry(): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
});
if (!confirmed) {
return;
}
await removeMQTTDeviceEntry(this.hass!, this.device.id);
}
static get styles(): CSSResult {
return haStyle;
}
}

View File

@ -1,133 +0,0 @@
import {
DeviceRegistryEntry,
computeDeviceName,
} from "../../../../data/device_registry";
import { loadDeviceRegistryDetailDialog } from "../../../../dialogs/device-registry-detail/show-dialog-device-registry-detail";
import {
LitElement,
html,
customElement,
property,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import { HomeAssistant } from "../../../../types";
import { AreaRegistryEntry } from "../../../../data/area_registry";
@customElement("ha-device-card")
export class HaDeviceCard extends LitElement {
@property() public hass!: HomeAssistant;
@property() public device!: DeviceRegistryEntry;
@property() public devices!: DeviceRegistryEntry[];
@property() public areas!: AreaRegistryEntry[];
@property() public narrow!: boolean;
protected render(): TemplateResult {
return html`
<div class="info">
${this.device.model
? html`
<div class="model">${this.device.model}</div>
`
: ""}
${this.device.manufacturer
? html`
<div class="manuf">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.manuf",
"manufacturer",
this.device.manufacturer
)}
</div>
`
: ""}
${this.device.area_id
? html`
<div class="area">
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.area",
"area",
this._computeArea(this.areas, this.device)
)}
</div>
</div>
`
: ""}
${this.device.via_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.via"
)}
<span class="hub"
>${this._computeDeviceName(
this.devices,
this.device.via_device_id
)}</span
>
</div>
`
: ""}
${this.device.sw_version
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.firmware",
"version",
this.device.sw_version
)}
</div>
`
: ""}
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
}
private _computeArea(areas, device) {
if (!areas || !device || !device.area_id) {
return "No Area";
}
// +1 because of "No Area" entry
return areas.find((area) => area.area_id === device.area_id).name;
}
private _computeDeviceName(devices, deviceId) {
const device = devices.find((dev) => dev.id === deviceId);
return device
? computeDeviceName(device, this.hass)
: `(${this.hass.localize(
"ui.panel.config.integrations.config_entry.device_unavailable"
)})`;
}
static get styles(): CSSResult {
return css`
ha-card {
flex: 1 0 100%;
padding-bottom: 10px;
min-width: 0;
}
.device {
width: 30%;
}
.area {
color: var(--primary-text-color);
}
.extra-info {
margin-top: 8px;
}
.manuf,
.entity-id,
.model {
color: var(--secondary-text-color);
}
`;
}
}

View File

@ -166,6 +166,9 @@ export class HaDeviceEntitiesCard extends LitElement {
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
:host {
display: block;
}
ha-icon { ha-icon {
width: 40px; width: 40px;
} }
@ -182,6 +185,9 @@ export class HaDeviceEntitiesCard extends LitElement {
#entities > * { #entities > * {
margin: 8px 16px 8px 8px; margin: 8px 16px 8px 8px;
} }
#entities > paper-icon-item {
margin: 0;
}
paper-icon-item { paper-icon-item {
min-height: 40px; min-height: 40px;
padding: 0 8px; padding: 0 8px;

View File

@ -0,0 +1,118 @@
import {
DeviceRegistryEntry,
computeDeviceName,
} from "../../../../data/device_registry";
import { loadDeviceRegistryDetailDialog } from "../../../../dialogs/device-registry-detail/show-dialog-device-registry-detail";
import {
LitElement,
html,
customElement,
property,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import { HomeAssistant } from "../../../../types";
import { AreaRegistryEntry } from "../../../../data/area_registry";
@customElement("ha-device-info-card")
export class HaDeviceCard extends LitElement {
@property() public hass!: HomeAssistant;
@property() public device!: DeviceRegistryEntry;
@property() public devices!: DeviceRegistryEntry[];
@property() public areas!: AreaRegistryEntry[];
@property() public narrow!: boolean;
protected render(): TemplateResult {
return html`
<ha-card header="Device info">
<div class="card-content">
${this.device.model
? html`
<div class="model">${this.device.model}</div>
`
: ""}
${this.device.manufacturer
? html`
<div class="manuf">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.manuf",
"manufacturer",
this.device.manufacturer
)}
</div>
`
: ""}
${this.device.via_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.via"
)}
<span class="hub"
>${this._computeDeviceName(
this.devices,
this.device.via_device_id
)}</span
>
</div>
`
: ""}
${this.device.sw_version
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.firmware",
"version",
this.device.sw_version
)}
</div>
`
: ""}
<slot></slot>
</div>
</ha-card>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
}
private _computeDeviceName(devices, deviceId) {
const device = devices.find((dev) => dev.id === deviceId);
return device
? computeDeviceName(device, this.hass)
: `(${this.hass.localize(
"ui.panel.config.integrations.config_entry.device_unavailable"
)})`;
}
static get styles(): CSSResult {
return css`
:host {
display: block;
}
ha-card {
flex: 1 0 100%;
padding-bottom: 10px;
min-width: 0;
}
.device {
width: 30%;
}
.area {
color: var(--primary-text-color);
}
.extra-info {
margin-top: 8px;
}
.manuf,
.entity-id,
.model {
color: var(--secondary-text-color);
}
`;
}
}

View File

@ -15,13 +15,15 @@ import "../../../layouts/hass-tabs-subpage";
import "../../../layouts/hass-error-screen"; import "../../../layouts/hass-error-screen";
import "../ha-config-section"; import "../ha-config-section";
import "./device-detail/ha-device-card"; import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-card-mqtt";
import "./device-detail/ha-device-entities-card"; import "./device-detail/ha-device-entities-card";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { ConfigEntry } from "../../../data/config_entries"; import { ConfigEntry } from "../../../data/config_entries";
import { import {
EntityRegistryEntry, EntityRegistryEntry,
updateEntityRegistryEntry, updateEntityRegistryEntry,
findBatteryEntity,
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { import {
DeviceRegistryEntry, DeviceRegistryEntry,
@ -40,9 +42,9 @@ import { createValidEntityId } from "../../../common/entity/valid_entity_id";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { RelatedResult, findRelated } from "../../../data/search"; import { RelatedResult, findRelated } from "../../../data/search";
import { SceneEntities, showSceneEditor } from "../../../data/scene"; import { SceneEntities, showSceneEditor } from "../../../data/scene";
import { navigate } from "../../../common/navigate";
import { showDeviceAutomationDialog } from "./device-detail/show-dialog-device-automation"; import { showDeviceAutomationDialog } from "./device-detail/show-dialog-device-automation";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ifDefined } from "lit-html/directives/if-defined";
export interface EntityRegistryStateEntry extends EntityRegistryEntry { export interface EntityRegistryStateEntry extends EntityRegistryEntry {
stateName?: string; stateName?: string;
@ -70,6 +72,13 @@ export class HaConfigDevicePage extends LitElement {
devices ? devices.find((device) => device.id === deviceId) : undefined devices ? devices.find((device) => device.id === deviceId) : undefined
); );
private _integrations = memoizeOne(
(device: DeviceRegistryEntry, entries: ConfigEntry[]): string[] =>
entries
.filter((entry) => device.config_entries.includes(entry.entry_id))
.map((entry) => entry.domain)
);
private _entities = memoizeOne( private _entities = memoizeOne(
( (
deviceId: string, deviceId: string,
@ -88,6 +97,10 @@ export class HaConfigDevicePage extends LitElement {
) )
); );
private _batteryEntity = memoizeOne((entities: EntityRegistryEntry[]):
| EntityRegistryEntry
| undefined => findBatteryEntity(this.hass, entities));
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog(); loadDeviceRegistryDetailDialog();
@ -113,7 +126,13 @@ export class HaConfigDevicePage extends LitElement {
`; `;
} }
const integrations = this._integrations(device, this.entries);
const entities = this._entities(this.deviceId, this.entities); const entities = this._entities(this.deviceId, this.entities);
const batteryEntity = this._batteryEntity(entities);
const batteryState = batteryEntity
? this.hass.states[batteryEntity.entity_id]
: undefined;
const areaName = this._computeAreaName(this.areas, device);
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
@ -139,22 +158,70 @@ export class HaConfigDevicePage extends LitElement {
></paper-icon-button> ></paper-icon-button>
<div class="container"> <div class="container">
<div class="left"> <div class="header fullwidth">
<div class="device-info"> ${
${ this.narrow
this.narrow ? ""
? "" : html`
: html` <div>
<h1>${computeDeviceName(device, this.hass)}</h1> <h1>${computeDeviceName(device, this.hass)}</h1>
` ${areaName
} ? this.hass.localize(
<ha-device-card "ui.panel.config.integrations.config_entry.area",
"area",
areaName
)
: ""}
</div>
`
}
<div class="header-right">
${
batteryState
? html`
<div class="battery">
${batteryState.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${batteryState}
></ha-state-icon>
</div>
`
: ""
}
<img
src="https://brands.home-assistant.io/${
integrations[0]
}/logo.png"
srcset="
https://brands.home-assistant.io/${
integrations[0]
}/logo@2x.png 2x
"
referrerpolicy="no-referrer"
@load=${this._onImageLoad}
@error=${this._onImageError}
/>
</div>
</div>
<div class="column">
<ha-device-info-card
.hass=${this.hass} .hass=${this.hass}
.areas=${this.areas} .areas=${this.areas}
.devices=${this.devices} .devices=${this.devices}
.device=${device} .device=${device}
></ha-device-card> >
</div> ${
integrations.includes("mqtt")
? html`
<ha-device-card-mqtt
.hass=${this.hass}
.device=${device}
></ha-device-card-mqtt>
`
: html``
}
</ha-device-info-card>
${ ${
entities.length entities.length
@ -168,32 +235,46 @@ export class HaConfigDevicePage extends LitElement {
: html`` : html``
} }
</div> </div>
<div class="right">
<div class="column"> <div class="column">
${ ${
isComponentLoaded(this.hass, "automation") isComponentLoaded(this.hass, "automation")
? html` ? html`
<ha-card <ha-card>
.header=${this.hass.localize( <div class="card-header">
"ui.panel.config.devices.automation.automations" ${this.hass.localize(
)} "ui.panel.config.devices.automation.automations"
>${this._related?.automation?.length )}
<paper-icon-button
@click=${this._showAutomationDialog}
title=${this.hass.localize(
"ui.panel.config.devices.automation.create"
)}
icon="hass:plus-circle"
></paper-icon-button>
</div>
${this._related?.automation?.length
? this._related.automation.map((automation) => { ? this._related.automation.map((automation) => {
const state = this.hass.states[automation]; const state = this.hass.states[automation];
return state return state
? html` ? html`
<div> <div>
<paper-item <a
.automation=${state} href=${ifDefined(
@click=${this._openAutomation} state.attributes.id
.disabled=${!state.attributes.id} ? `/config/automation/edit/${state.attributes.id}`
: undefined
)}
> >
<paper-item-body> <paper-item
${state.attributes.friendly_name || .automation=${state}
automation} .disabled=${!state.attributes.id}
</paper-item-body> >
<ha-icon-next></ha-icon-next> <paper-item-body>
</paper-item> ${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
${!state.attributes.id ${!state.attributes.id
? html` ? html`
<paper-tooltip <paper-tooltip
@ -214,13 +295,6 @@ export class HaConfigDevicePage extends LitElement {
)}</paper-item )}</paper-item
> >
`} `}
<div class="card-actions">
<mwc-button @click=${this._showAutomationDialog}>
${this.hass.localize(
"ui.panel.config.devices.automation.create"
)}
</mwc-button>
</div>
</ha-card> </ha-card>
` `
: "" : ""
@ -230,58 +304,72 @@ export class HaConfigDevicePage extends LitElement {
${ ${
isComponentLoaded(this.hass, "scene") isComponentLoaded(this.hass, "scene")
? html` ? html`
<ha-card <ha-card>
.header=${this.hass.localize( <div class="card-header">
"ui.panel.config.devices.scene.scenes" ${this.hass.localize(
)} "ui.panel.config.devices.scene.scenes"
>${this._related?.scene?.length )}
? this._related.scene.map((scene) => { ${
const state = this.hass.states[scene]; entities.length
return state
? html` ? html`
<div> <paper-icon-button
<paper-item @click=${this._createScene}
.scene=${state} title=${this.hass.localize(
@click=${this._openScene} "ui.panel.config.devices.scene.create"
.disabled=${!state.attributes.id} )}
> icon="hass:plus-circle"
<paper-item-body> ></paper-icon-button>
${state.attributes.friendly_name ||
scene}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
${!state.attributes.id
? html`
<paper-tooltip
>${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</paper-tooltip>
`
: ""}
</div>
` `
: ""; : ""
}) }
: html` </div>
<paper-item class="no-link"
>${this.hass.localize( ${
"ui.panel.config.devices.scene.no_scenes" this._related?.scene?.length
)}</paper-item ? this._related.scene.map((scene) => {
> const state = this.hass.states[scene];
`} return state
${entities.length ? html`
? html` <div>
<div class="card-actions"> <a
<mwc-button @click=${this._createScene}> href=${ifDefined(
${this.hass.localize( state.attributes.id
"ui.panel.config.devices.scene.create" ? `/config/scene/edit/${state.attributes.id}`
)} : undefined
</mwc-button> )}
</div> >
` <paper-item
: ""} .scene=${state}
.disabled=${!state.attributes.id}
>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
${!state.attributes.id
? html`
<paper-tooltip
>${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</paper-tooltip>
`
: ""}
</div>
`
: "";
})
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}</paper-item
>
`
}
</ha-card>
</ha-card> </ha-card>
` `
: "" : ""
@ -289,25 +377,38 @@ export class HaConfigDevicePage extends LitElement {
${ ${
isComponentLoaded(this.hass, "script") isComponentLoaded(this.hass, "script")
? html` ? html`
<ha-card <ha-card>
.header=${this.hass.localize( <div class="card-header">
"ui.panel.config.devices.script.scripts" ${this.hass.localize(
)} "ui.panel.config.devices.script.scripts"
>${this._related?.script?.length )}
<paper-icon-button
@click=${this._showScriptDialog}
title=${this.hass.localize(
"ui.panel.config.devices.script.create"
)}
icon="hass:plus-circle"
></paper-icon-button>
</div>
${this._related?.script?.length
? this._related.script.map((script) => { ? this._related.script.map((script) => {
const state = this.hass.states[script]; const state = this.hass.states[script];
return state return state
? html` ? html`
<paper-item <a
.script=${script} href=${ifDefined(
@click=${this._openScript} state.attributes.id
? `/config/script/edit/${state.attributes.id}`
: undefined
)}
> >
<paper-item-body> <paper-item .script=${script}>
${state.attributes.friendly_name || <paper-item-body>
script} ${computeStateName(state)}
</paper-item-body> </paper-item-body>
<ha-icon-next></ha-icon-next> <ha-icon-next></ha-icon-next>
</paper-item> </paper-item>
</a>
` `
: ""; : "";
}) })
@ -318,19 +419,11 @@ export class HaConfigDevicePage extends LitElement {
)}</paper-item )}</paper-item
> >
`} `}
<div class="card-actions">
<mwc-button @click=${this._showScriptDialog}>
${this.hass.localize(
"ui.panel.config.devices.script.create"
)}
</mwc-button>
</div>
</ha-card> </ha-card>
` `
: "" : ""
} }
</div> </div>
</div>
</div> </div>
</ha-config-section> </ha-config-section>
</hass-tabs-subpage> `; </hass-tabs-subpage> `;
@ -344,6 +437,21 @@ export class HaConfigDevicePage extends LitElement {
return state ? computeStateName(state) : null; return state ? computeStateName(state) : null;
} }
private _computeAreaName(areas, device): string | undefined {
if (!areas || !device || !device.area_id) {
return undefined;
}
return areas.find((area) => area.area_id === device.area_id).name;
}
private _onImageLoad(ev) {
ev.target.style.display = "inline-block";
}
private _onImageError(ev) {
ev.target.style.display = "none";
}
private async _findRelated() { private async _findRelated() {
this._related = await findRelated(this.hass, "device", this.deviceId); this._related = await findRelated(this.hass, "device", this.deviceId);
} }
@ -358,25 +466,6 @@ export class HaConfigDevicePage extends LitElement {
}); });
} }
private _openScene(ev: Event) {
const state = (ev.currentTarget as any).scene;
if (state.attributes.id) {
navigate(this, `/config/scene/edit/${state.attributes.id}`);
}
}
private _openScript(ev: Event) {
const script = (ev.currentTarget as any).script;
navigate(this, `/config/script/edit/${script}`);
}
private _openAutomation(ev: Event) {
const state = (ev.currentTarget as any).automation;
if (state.attributes.id) {
navigate(this, `/config/automation/edit/${state.attributes.id}`);
}
}
private _showScriptDialog() { private _showScriptDialog() {
showDeviceAutomationDialog(this, { deviceId: this.deviceId, script: true }); showDeviceAutomationDialog(this, { deviceId: this.deviceId, script: true });
} }
@ -459,6 +548,18 @@ export class HaConfigDevicePage extends LitElement {
margin-bottom: 32px; margin-bottom: 32px;
} }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header paper-icon-button {
margin-right: -8px;
color: var(--primary-color);
height: auto;
}
.device-info { .device-info {
padding: 16px; padding: 16px;
} }
@ -467,7 +568,7 @@ export class HaConfigDevicePage extends LitElement {
} }
h1 { h1 {
margin-top: 0; margin: 0;
font-family: var(--paper-font-headline_-_font-family); font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var( -webkit-font-smoothing: var(
--paper-font-headline_-_-webkit-font-smoothing --paper-font-headline_-_-webkit-font-smoothing
@ -479,46 +580,60 @@ export class HaConfigDevicePage extends LitElement {
opacity: var(--dark-primary-opacity); opacity: var(--dark-primary-opacity);
} }
.left, .header {
display: flex;
justify-content: space-between;
}
.column, .column,
.fullwidth { .fullwidth {
padding: 8px; padding: 8px;
box-sizing: border-box; box-sizing: border-box;
} }
.column {
.left { width: 33%;
width: 33.33%; flex-grow: 1;
padding-bottom: 0;
} }
.right {
width: 66.66%;
display: flex;
flex-wrap: wrap;
}
.fullwidth { .fullwidth {
width: 100%; width: 100%;
flex-grow: 1;
} }
.column { .header-right {
width: 50%; align-self: center;
}
.header-right img {
height: 30px;
}
.header-right {
display: flex;
}
.header-right:first-child {
width: 100%;
justify-content: flex-end;
}
.header-right > *:not(:first-child) {
margin-left: 16px;
}
.battery {
align-self: center;
align-items: center;
display: flex;
} }
.column > *:not(:first-child) { .column > *:not(:first-child) {
margin-top: 16px; margin-top: 16px;
} }
:host([narrow]) .left,
:host([narrow]) .right,
:host([narrow]) .column { :host([narrow]) .column {
width: 100%; width: 100%;
} }
:host([narrow]) .container > *:first-child {
padding-top: 0;
}
:host([narrow]) .container { :host([narrow]) .container {
margin-top: 0; margin-top: 0;
} }
@ -530,6 +645,11 @@ export class HaConfigDevicePage extends LitElement {
paper-item.no-link { paper-item.no-link {
cursor: default; cursor: default;
} }
a {
text-decoration: none;
color: var(--primary-text-color);
}
`; `;
} }
} }

View File

@ -13,7 +13,10 @@ import {
computeDeviceName, computeDeviceName,
DeviceEntityLookup, DeviceEntityLookup,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry"; import {
EntityRegistryEntry,
findBatteryEntity,
} from "../../../data/entity_registry";
import { ConfigEntry } from "../../../data/config_entries"; import { ConfigEntry } from "../../../data/config_entries";
import { AreaRegistryEntry } from "../../../data/area_registry"; import { AreaRegistryEntry } from "../../../data/area_registry";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
@ -130,25 +133,38 @@ export class HaConfigDeviceDashboard extends LitElement {
direction: "asc", direction: "asc",
grows: true, grows: true,
template: (name, device: DataTableRowData) => { template: (name, device: DataTableRowData) => {
const battery = device.battery_entity
? this.hass.states[device.battery_entity]
: undefined;
// Have to work on a nice layout for mobile
return html` return html`
${name}<br /> ${name}
${device.area} | ${device.integration}<br /> <div class="secondary">
${battery && !isNaN(battery.state as any) ${device.area} | ${device.integration}
? html` </div>
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: ""}
`; `;
}, },
}, },
battery_entity: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.battery"
),
sortable: true,
type: "numeric",
width: "90px",
template: (batteryEntity: string) => {
const battery = batteryEntity
? this.hass.states[batteryEntity]
: undefined;
return battery
? html`
${isNaN(battery.state as any) ? "-" : battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: html`
-
`;
},
},
} }
: { : {
name: { name: {
@ -198,7 +214,8 @@ export class HaConfigDeviceDashboard extends LitElement {
), ),
sortable: true, sortable: true,
type: "numeric", type: "numeric",
width: "60px", width: "15%",
maxWidth: "90px",
template: (batteryEntity: string) => { template: (batteryEntity: string) => {
const battery = batteryEntity const battery = batteryEntity
? this.hass.states[batteryEntity] ? this.hass.states[batteryEntity]
@ -246,12 +263,10 @@ export class HaConfigDeviceDashboard extends LitElement {
deviceId: string, deviceId: string,
deviceEntityLookup: DeviceEntityLookup deviceEntityLookup: DeviceEntityLookup
): string | undefined { ): string | undefined {
const batteryEntity = (deviceEntityLookup[deviceId] || []).find( const batteryEntity = findBatteryEntity(
(entity) => this.hass,
this.hass.states[entity.entity_id] && deviceEntityLookup[deviceId] || []
this.hass.states[entity.entity_id].attributes.device_class === "battery"
); );
return batteryEntity ? batteryEntity.entity_id : undefined; return batteryEntity ? batteryEntity.entity_id : undefined;
} }

View File

@ -23,7 +23,10 @@ import {
computeDeviceName, computeDeviceName,
DeviceEntityLookup, DeviceEntityLookup,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry"; import {
EntityRegistryEntry,
findBatteryEntity,
} from "../../../data/entity_registry";
import { ConfigEntry } from "../../../data/config_entries"; import { ConfigEntry } from "../../../data/config_entries";
import { AreaRegistryEntry } from "../../../data/area_registry"; import { AreaRegistryEntry } from "../../../data/area_registry";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
@ -204,7 +207,8 @@ export class HaDevicesDataTable extends LitElement {
), ),
sortable: true, sortable: true,
type: "numeric", type: "numeric",
width: "60px", width: "15%",
maxWidth: "90px",
template: (batteryEntity: string) => { template: (batteryEntity: string) => {
const battery = batteryEntity const battery = batteryEntity
? this.hass.states[batteryEntity] ? this.hass.states[batteryEntity]
@ -250,12 +254,10 @@ export class HaDevicesDataTable extends LitElement {
deviceId: string, deviceId: string,
deviceEntityLookup: DeviceEntityLookup deviceEntityLookup: DeviceEntityLookup
): string | undefined { ): string | undefined {
const batteryEntity = (deviceEntityLookup[deviceId] || []).find( const batteryEntity = findBatteryEntity(
(entity) => this.hass,
this.hass.states[entity.entity_id] && deviceEntityLookup[deviceId] || []
this.hass.states[entity.entity_id].attributes.device_class === "battery"
); );
return batteryEntity ? batteryEntity.entity_id : undefined; return batteryEntity ? batteryEntity.entity_id : undefined;
} }

View File

@ -132,19 +132,16 @@ class HaInputNumberForm extends LitElement {
</paper-radio-button> </paper-radio-button>
</paper-radio-group> </paper-radio-group>
</div> </div>
${this._mode === "slider" <paper-input
? html` .value=${this._step}
<paper-input .configValue=${"step"}
.value=${this._step} type="number"
.configValue=${"step"} @value-changed=${this._valueChanged}
type="number" .label=${this.hass!.localize(
@value-changed=${this._valueChanged} "ui.dialogs.helper_settings.input_number.step"
.label=${this.hass!.localize( )}
"ui.dialogs.helper_settings.input_number.step" ></paper-input>
)}
></paper-input>
`
: ""}
<paper-input <paper-input
.value=${this._unit_of_measurement} .value=${this._unit_of_measurement}
.configValue=${"unit_of_measurement"} .configValue=${"unit_of_measurement"}

View File

@ -344,9 +344,6 @@ export class HaConfigManagerDashboard extends LitElement {
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
ha-card {
overflow: hidden;
}
mwc-button { mwc-button {
align-self: center; align-self: center;
} }

View File

@ -19,6 +19,7 @@ import { PolymerChangedEvent } from "../../../../polymer-types";
import { HaSwitch } from "../../../../components/ha-switch"; import { HaSwitch } from "../../../../components/ha-switch";
import { createCloseHeading } from "../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles"; import { haStyleDialog } from "../../../../resources/styles";
import { setDefaultPanel, DEFAULT_PANEL } from "../../../../data/panel";
@customElement("dialog-lovelace-dashboard-detail") @customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends LitElement { export class DialogLovelaceDashboardDetail extends LitElement {
@ -57,6 +58,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!this._params) { if (!this._params) {
return html``; return html``;
} }
const defaultPanelUrlPath = this.hass.defaultPanel;
const urlInvalid = const urlInvalid =
this._params.urlPath !== "lovelace" && this._params.urlPath !== "lovelace" &&
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(this._urlPath); !/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(this._urlPath);
@ -169,12 +171,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
slot="secondaryAction" slot="secondaryAction"
@click=${this._toggleDefault} @click=${this._toggleDefault}
.disabled=${this._params.urlPath === "lovelace" && .disabled=${this._params.urlPath === "lovelace" &&
(!localStorage.defaultPage || defaultPanelUrlPath === "lovelace"}
localStorage.defaultPage === "lovelace")}
> >
${this._params.urlPath === localStorage.defaultPage || ${this._params.urlPath === defaultPanelUrlPath
(this._params.urlPath === "lovelace" &&
!localStorage.defaultPage)
? this.hass.localize( ? this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.remove_default" "ui.panel.config.lovelace.dashboards.detail.remove_default"
) )
@ -244,12 +243,10 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!urlPath) { if (!urlPath) {
return; return;
} }
if (urlPath === localStorage.defaultPage) { setDefaultPanel(
delete localStorage.defaultPage; this,
} else { urlPath === this.hass.defaultPanel ? DEFAULT_PANEL : urlPath
localStorage.defaultPage = urlPath; );
}
location.reload();
} }
private async _updateDashboard() { private async _updateDashboard() {

View File

@ -184,8 +184,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
private _getItems = memoize((dashboards: LovelaceDashboard[]) => { private _getItems = memoize((dashboards: LovelaceDashboard[]) => {
const defaultMode = (this.hass.panels?.lovelace const defaultMode = (this.hass.panels?.lovelace
?.config as LovelacePanelConfig).mode; ?.config as LovelacePanelConfig).mode;
const isDefault = const defaultUrlPath = this.hass.defaultPanel;
!localStorage.defaultPage || localStorage.defaultPage === "lovelace"; const isDefault = defaultUrlPath === "lovelace";
return [ return [
{ {
icon: "hass:view-dashboard", icon: "hass:view-dashboard",
@ -201,7 +201,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
return { return {
filename: "", filename: "",
...dashboard, ...dashboard,
default: localStorage.defaultPage === dashboard.url_path, default: defaultUrlPath === dashboard.url_path,
}; };
}), }),
]; ];

View File

@ -1,30 +1,28 @@
import {
LitElement,
TemplateResult,
html,
CSSResultArray,
css,
property,
customElement,
} from "lit-element";
import "@polymer/paper-icon-button/paper-icon-button"; import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-tooltip/paper-tooltip"; import "@polymer/paper-tooltip/paper-tooltip";
import "../../../layouts/hass-tabs-subpage"; import {
css,
import "../../../components/ha-card"; CSSResultArray,
import "../../../components/ha-fab"; customElement,
html,
import "../ha-config-section"; LitElement,
property,
TemplateResult,
} from "lit-element";
import { ifDefined } from "lit-html/directives/if-defined";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeRTL } from "../../../common/util/compute_rtl"; import { computeRTL } from "../../../common/util/compute_rtl";
import { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import { forwardHaptic } from "../../../data/haptics";
import { activateScene, SceneEntity } from "../../../data/scene";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { SceneEntity, activateScene } from "../../../data/scene";
import { showToast } from "../../../util/toast"; import { showToast } from "../../../util/toast";
import { ifDefined } from "lit-html/directives/if-defined";
import { forwardHaptic } from "../../../data/haptics";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
@customElement("ha-scene-dashboard") @customElement("ha-scene-dashboard")
@ -35,108 +33,131 @@ class HaSceneDashboard extends LitElement {
@property() public route!: Route; @property() public route!: Route;
@property() public scenes!: SceneEntity[]; @property() public scenes!: SceneEntity[];
private _scenes = memoizeOne((scenes: SceneEntity[]) => {
return scenes.map((scene) => {
return {
...scene,
name: computeStateName(scene),
};
});
});
private _columns = memoizeOne(
(_language): DataTableColumnContainer => {
return {
activate: {
title: "",
type: "icon-button",
template: (_toggle, scene) =>
html`
<paper-icon-button
.scene=${scene}
icon="hass:play"
title="${this.hass.localize(
"ui.panel.config.scene.picker.activate_scene"
)}"
@click=${(ev: Event) => this._activateScene(ev)}
></paper-icon-button>
`,
},
name: {
title: this.hass.localize(
"ui.panel.config.scene.picker.headers.name"
),
sortable: true,
filterable: true,
direction: "asc",
grows: true,
},
info: {
title: "",
type: "icon-button",
template: (_info, scene) => html`
<paper-icon-button
.scene=${scene}
@click=${this._showInfo}
icon="hass:information-outline"
title="${this.hass.localize(
"ui.panel.config.scene.picker.show_info_scene"
)}"
></paper-icon-button>
`,
},
edit: {
title: "",
type: "icon-button",
template: (_info, scene: any) => html`
<a
href=${ifDefined(
scene.attributes.id
? `/config/scene/edit/${scene.attributes.id}`
: undefined
)}
>
<paper-icon-button
.icon=${scene.attributes.id ? "hass:pencil" : "hass:pencil-off"}
.disabled=${!scene.attributes.id}
title="${this.hass.localize(
"ui.panel.config.scene.picker.edit_scene"
)}"
></paper-icon-button>
</a>
${!scene.attributes.id
? html`
<paper-tooltip position="left">
${this.hass.localize(
"ui.panel.config.scene.picker.only_editable"
)}
</paper-tooltip>
`
: ""}
`,
},
};
}
);
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
back-path="/config" back-path="/config"
.route=${this.route} .route=${this.route}
.tabs=${configSections.automation} .tabs=${configSections.automation}
.columns=${this._columns(this.hass.language)}
.data=${this._scenes(this.scenes)}
id="entity_id"
.noDataText=${this.hass.localize(
"ui.panel.config.scene.picker.no_scenes"
)}
> >
<ha-config-section .isWide=${this.isWide}> <paper-icon-button
<div slot="header"> slot="toolbar-icon"
${this.hass.localize("ui.panel.config.scene.picker.header")} icon="hass:help-circle"
</div> @click=${this._showHelp}
<div slot="introduction"> ></paper-icon-button>
${this.hass.localize("ui.panel.config.scene.picker.introduction")} </hass-tabs-subpage-data-table>
<p> <a href="/config/scene/edit/new">
<a <ha-fab
href="https://home-assistant.io/docs/scene/editor/" ?is-wide=${this.isWide}
target="_blank" ?narrow=${this.narrow}
rel="noreferrer" icon="hass:plus"
> title=${this.hass.localize("ui.panel.config.scene.picker.add_scene")}
${this.hass.localize("ui.panel.config.scene.picker.learn_more")} ?rtl=${computeRTL(this.hass)}
</a> ></ha-fab>
</p> </a>
</div>
<ha-card
.heading=${this.hass.localize(
"ui.panel.config.scene.picker.pick_scene"
)}
>
${this.scenes.length === 0
? html`
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.scene.picker.no_scenes"
)}
</p>
</div>
`
: this.scenes.map(
(scene) => html`
<div class="scene">
<paper-icon-button
.scene=${scene}
icon="hass:play"
title="${this.hass.localize(
"ui.panel.config.scene.picker.activate_scene"
)}"
@click=${this._activateScene}
></paper-icon-button>
<paper-item-body two-line>
<div>${computeStateName(scene)}</div>
</paper-item-body>
<div class="actions">
<a
href=${ifDefined(
scene.attributes.id
? `/config/scene/edit/${scene.attributes.id}`
: undefined
)}
>
<paper-icon-button
title="${this.hass.localize(
"ui.panel.config.scene.picker.edit_scene"
)}"
icon="hass:pencil"
.disabled=${!scene.attributes.id}
></paper-icon-button>
${!scene.attributes.id
? html`
<paper-tooltip position="left">
${this.hass.localize(
"ui.panel.config.scene.picker.only_editable"
)}
</paper-tooltip>
`
: ""}
</a>
</div>
</div>
`
)}
</ha-card>
</ha-config-section>
<a href="/config/scene/edit/new">
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title=${this.hass.localize(
"ui.panel.config.scene.picker.add_scene"
)}
?rtl=${computeRTL(this.hass)}
></ha-fab>
</a>
</hass-tabs-subpage>
`; `;
} }
private _showInfo(ev) {
ev.stopPropagation();
const entityId = ev.currentTarget.scene.entity_id;
fireEvent(this, "hass-more-info", { entityId });
}
private async _activateScene(ev) { private async _activateScene(ev) {
ev.stopPropagation();
const scene = ev.target.scene as SceneEntity; const scene = ev.target.scene as SceneEntity;
await activateScene(this.hass, scene.entity_id); await activateScene(this.hass, scene.entity_id);
showToast(this, { showToast(this, {
@ -149,38 +170,28 @@ class HaSceneDashboard extends LitElement {
forwardHaptic("light"); forwardHaptic("light");
} }
private _showHelp() {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.scene.picker.header"),
text: html`
${this.hass.localize("ui.panel.config.scene.picker.introduction")}
<p>
<a
href="https://home-assistant.io/docs/scene/editor/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.config.scene.picker.learn_more")}
</a>
</p>
`,
});
}
static get styles(): CSSResultArray { static get styles(): CSSResultArray {
return [ return [
haStyle, haStyle,
css` css`
:host {
display: block;
height: 100%;
}
ha-card {
margin-bottom: 56px;
}
.scene {
display: flex;
flex-direction: horizontal;
align-items: center;
padding: 0 8px 0 16px;
}
.scene > *:first-child {
margin-right: 8px;
}
.scene a[href] {
color: var(--primary-text-color);
}
.actions {
display: flex;
}
ha-fab { ha-fab {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;

View File

@ -1,31 +1,28 @@
import {
LitElement,
html,
CSSResultArray,
css,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-icon-button/paper-icon-button"; import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-item/paper-item-body";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import {
import "../../../layouts/hass-tabs-subpage"; css,
CSSResultArray,
import { computeRTL } from "../../../common/util/compute_rtl"; customElement,
html,
import "../../../components/ha-card"; LitElement,
import "../../../components/ha-fab"; property,
TemplateResult,
import "../ha-config-section"; } from "lit-element";
import memoizeOne from "memoize-one";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeRTL } from "../../../common/util/compute_rtl";
import { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import { triggerScript } from "../../../data/script";
import "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { triggerScript } from "../../../data/script";
import { showToast } from "../../../util/toast"; import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-script-picker") @customElement("ha-script-picker")
class HaScriptPicker extends LitElement { class HaScriptPicker extends LitElement {
@ -35,91 +32,123 @@ class HaScriptPicker extends LitElement {
@property() public narrow!: boolean; @property() public narrow!: boolean;
@property() public route!: Route; @property() public route!: Route;
private _scripts = memoizeOne((scripts: HassEntity[]) => {
return scripts.map((script) => {
return {
...script,
name: computeStateName(script),
};
});
});
private _columns = memoizeOne(
(_language): DataTableColumnContainer => {
return {
activate: {
title: "",
type: "icon-button",
template: (_toggle, script) =>
html`
<paper-icon-button
.script=${script}
icon="hass:play"
title="${this.hass.localize(
"ui.panel.config.script.picker.activate_script"
)}"
@click=${(ev: Event) => this._runScript(ev)}
></paper-icon-button>
`,
},
name: {
title: this.hass.localize(
"ui.panel.config.script.picker.headers.name"
),
sortable: true,
filterable: true,
direction: "asc",
grows: true,
template: (name, script: any) => html`
${name}
<div class="secondary">
${this.hass.localize("ui.card.automation.last_triggered")}:
${script.attributes.last_triggered
? formatDateTime(
new Date(script.attributes.last_triggered),
this.hass.language
)
: this.hass.localize("ui.components.relative_time.never")}
</div>
`,
},
info: {
title: "",
type: "icon-button",
template: (_info, script) => html`
<paper-icon-button
.script=${script}
@click=${this._showInfo}
icon="hass:information-outline"
title="${this.hass.localize(
"ui.panel.config.script.picker.show_info"
)}"
></paper-icon-button>
`,
},
edit: {
title: "",
type: "icon-button",
template: (_info, script: any) => html`
<a href="/config/script/edit/${script.entity_id}">
<paper-icon-button
icon="hass:pencil"
title="${this.hass.localize(
"ui.panel.config.script.picker.edit_script"
)}"
></paper-icon-button>
</a>
`,
},
};
}
);
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
back-path="/config" back-path="/config"
.route=${this.route} .route=${this.route}
.tabs=${configSections.automation} .tabs=${configSections.automation}
.columns=${this._columns(this.hass.language)}
.data=${this._scripts(this.scripts)}
id="entity_id"
.noDataText=${this.hass.localize(
"ui.panel.config.script.picker.no_scripts"
)}
> >
<ha-config-section .isWide=${this.isWide}> <paper-icon-button
<div slot="header"> slot="toolbar-icon"
${this.hass.localize("ui.panel.config.script.picker.header")} icon="hass:help-circle"
</div> @click=${this._showHelp}
<div slot="introduction"> ></paper-icon-button>
${this.hass.localize("ui.panel.config.script.picker.introduction")} </hass-tabs-subpage-data-table>
<p> <a href="/config/script/new">
<a <ha-fab
href="https://home-assistant.io/docs/scripts/editor/" ?is-wide=${this.isWide}
target="_blank" ?narrow=${this.narrow}
rel="noreferrer" icon="hass:plus"
> title="${this.hass.localize(
${this.hass.localize( "ui.panel.config.script.picker.add_script"
"ui.panel.config.script.picker.learn_more" )}"
)} ?rtl=${computeRTL(this.hass)}
</a> ></ha-fab>
</p> </a>
</div>
<ha-card>
${this.scripts.length === 0
? html`
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.script.picker.no_scripts"
)}
</p>
</div>
`
: this.scripts.map(
(script) => html`
<div class="script">
<paper-icon-button
.script=${script}
icon="hass:play"
title="${this.hass.localize(
"ui.panel.config.script.picker.trigger_script"
)}"
@click=${this._runScript}
></paper-icon-button>
<paper-item-body two-line>
<div>${computeStateName(script)}</div>
</paper-item-body>
<div class="actions">
<a href=${`/config/script/edit/${script.entity_id}`}>
<paper-icon-button
icon="hass:pencil"
title="${this.hass.localize(
"ui.panel.config.script.picker.edit_script"
)}"
></paper-icon-button>
</a>
</div>
</div>
`
)}
</ha-card>
</ha-config-section>
<a href="/config/script/new">
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.script.picker.add_script"
)}"
?rtl=${computeRTL(this.hass)}
></ha-fab>
</a>
</hass-tabs-subpage>
`; `;
} }
private async _runScript(ev) { private async _runScript(ev) {
ev.stopPropagation();
const script = ev.currentTarget.script as HassEntity; const script = ev.currentTarget.script as HassEntity;
await triggerScript(this.hass, script.entity_id); await triggerScript(this.hass, script.entity_id);
showToast(this, { showToast(this, {
@ -131,38 +160,34 @@ class HaScriptPicker extends LitElement {
}); });
} }
private _showInfo(ev) {
ev.stopPropagation();
const entityId = ev.currentTarget.script.entity_id;
fireEvent(this, "hass-more-info", { entityId });
}
private _showHelp() {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.script.caption"),
text: html`
${this.hass.localize("ui.panel.config.script.picker.introduction")}
<p>
<a
href="https://home-assistant.io/docs/scripts/editor/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.config.script.picker.learn_more")}
</a>
</p>
`,
});
}
static get styles(): CSSResultArray { static get styles(): CSSResultArray {
return [ return [
haStyle, haStyle,
css` css`
:host {
display: block;
}
ha-card {
margin-bottom: 56px;
}
.script {
display: flex;
flex-direction: horizontal;
align-items: center;
padding: 0 8px 0 16px;
}
.script > *:first-child {
margin-right: 8px;
}
.script a[href],
paper-icon-button {
color: var(--primary-text-color);
}
.actions {
display: flex;
}
ha-fab { ha-fab {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
@ -187,10 +212,6 @@ class HaScriptPicker extends LitElement {
right: auto; right: auto;
left: 24px; left: 24px;
} }
a {
color: var(--primary-color);
}
`, `,
]; ];
} }

View File

@ -0,0 +1,242 @@
import "@material/mwc-button";
import "@polymer/paper-spinner/paper-spinner";
import "../../../components/ha-dialog";
import "../../../resources/ha-style";
import {
LitElement,
html,
TemplateResult,
customElement,
property,
PropertyValues,
} from "lit-element";
import { HomeAssistant } from "../../../types";
import { PolymerChangedEvent } from "../../../polymer-types";
import { AddUserDialogParams } from "./show-dialog-add-user";
import {
User,
SYSTEM_GROUP_ID_USER,
GROUPS,
createUser,
deleteUser,
} from "../../../data/user";
import { createAuthForUser } from "../../../data/auth";
@customElement("dialog-add-user")
export class DialogAddUser extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _loading = false;
// Error message when can't talk to server etc
@property() private _error?: string;
@property() private _params?: AddUserDialogParams;
@property() private _name?: string;
@property() private _username?: string;
@property() private _password?: string;
@property() private _group?: string;
public showDialog(params: AddUserDialogParams) {
this._params = params;
this._name = "";
this._username = "";
this._password = "";
this._group = SYSTEM_GROUP_ID_USER;
this._error = undefined;
this._loading = false;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this.addEventListener("keypress", (ev) => {
if (ev.keyCode === 13) {
this._createUser(ev);
}
});
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
return html`
<ha-dialog
open
@closing=${this._close}
scrimClickAction
escapeKeyAction
.heading=${this.hass.localize("ui.panel.config.users.add_user.caption")}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<paper-input
class="name"
.label=${this.hass.localize("ui.panel.config.users.add_user.name")}
.value=${this._name}
required
auto-validate
autocapitalize="on"
error-message="Required"
@value-changed=${this._nameChanged}
@blur=${this._maybePopulateUsername}
></paper-input>
<paper-input
class="username"
.label=${this.hass.localize(
"ui.panel.config.users.add_user.username"
)}
.value=${this._username}
required
auto-validate
autocapitalize="none"
@value-changed=${this._usernameChanged}
error-message="Required"
></paper-input>
<paper-input
.label=${this.hass.localize(
"ui.panel.config.users.add_user.password"
)}
type="password"
.value=${this._password}
required
auto-validate
@value-changed=${this._passwordChanged}
error-message="Required"
></paper-input>
<ha-paper-dropdown-menu
.label=${this.hass.localize("ui.panel.config.users.editor.group")}
>
<paper-listbox
slot="dropdown-content"
.selected=${this._group}
@iron-select=${this._handleGroupChange}
attr-for-selected="group-id"
>
${GROUPS.map(
(groupId) => html`
<paper-item group-id=${groupId}>
${this.hass.localize(`groups.${groupId}`)}
</paper-item>
`
)}
</paper-listbox>
</ha-paper-dropdown-menu>
${this._group === SYSTEM_GROUP_ID_USER
? html`
<br />
The users group is a work in progress. The user will be unable
to administer the instance via the UI. We're still auditing all
management API endpoints to ensure that they correctly limit
access to administrators.
`
: ""}
</div>
<mwc-button
slot="secondaryAction"
@click="${this._close}"
.disabled=${this._loading}
>
${this.hass!.localize("ui.common.cancel")}
</mwc-button>
${this._loading
? html`
<div slot="primaryAction" class="submit-spinner">
<paper-spinner active></paper-spinner>
</div>
`
: html`
<mwc-button
slot="primaryAction"
.disabled=${!this._name || !this._username || !this._password}
@click=${this._createUser}
>
${this.hass.localize("ui.panel.config.users.add_user.create")}
</mwc-button>
`}
</ha-dialog>
`;
}
private _close() {
this._params = undefined;
}
private _maybePopulateUsername() {
if (this._username || !this._name) {
return;
}
const parts = this._name.split(" ");
if (parts.length) {
this._username = parts[0].toLowerCase();
}
}
private _nameChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._name = ev.detail.value;
}
private _usernameChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._username = ev.detail.value;
}
private _passwordChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._password = ev.detail.value;
}
private async _handleGroupChange(ev): Promise<void> {
this._group = ev.detail.item.getAttribute("group-id");
}
private async _createUser(ev) {
ev.preventDefault();
if (!this._name || !this._username || !this._password) {
return;
}
this._loading = true;
this._error = "";
let user: User;
try {
const userResponse = await createUser(this.hass, this._name, [
this._group!,
]);
user = userResponse.user;
} catch (err) {
this._loading = false;
this._error = err.code;
return;
}
try {
await createAuthForUser(
this.hass,
user.id,
this._username,
this._password
);
} catch (err) {
await deleteUser(this.hass, user.id);
this._loading = false;
this._error = err.code;
return;
}
this._params!.userAddedCallback(user);
this._close();
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-add-user": DialogAddUser;
}
}

View File

@ -0,0 +1,209 @@
import "@material/mwc-button";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-tooltip/paper-tooltip";
import {
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
css,
} from "lit-element";
import "../../../components/entity/ha-entities-picker";
import "../../../components/user/ha-user-picker";
import { PolymerChangedEvent } from "../../../polymer-types";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { UserDetailDialogParams } from "./show-dialog-user-detail";
import { createCloseHeading } from "../../../components/ha-dialog";
import { GROUPS, SYSTEM_GROUP_ID_USER } from "../../../data/user";
@customElement("dialog-user-detail")
class DialogUserDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _name!: string;
@property() private _group?: string;
@property() private _error?: string;
@property() private _params?: UserDetailDialogParams;
@property() private _submitting: boolean = false;
public async showDialog(params: UserDetailDialogParams): Promise<void> {
this._params = params;
this._error = undefined;
this._name = params.entry.name || "";
this._group = params.entry.group_ids[0];
await this.updateComplete;
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
const user = this._params.entry;
return html`
<ha-dialog
open
@closing=${this._close}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(this.hass, user.name)}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<paper-input
.value=${this._name}
@value-changed=${this._nameChanged}
label="${this.hass!.localize("ui.panel.config.user.editor.name")}"
></paper-input>
<ha-paper-dropdown-menu
.label=${this.hass.localize("ui.panel.config.users.editor.group")}
>
<paper-listbox
slot="dropdown-content"
.selected=${this._group}
@iron-select=${this._handleGroupChange}
attr-for-selected="group-id"
>
${GROUPS.map(
(groupId) => html`
<paper-item group-id=${groupId}>
${this.hass.localize(`groups.${groupId}`)}
</paper-item>
`
)}
</paper-listbox>
</ha-paper-dropdown-menu>
${this._group === SYSTEM_GROUP_ID_USER
? html`
<br />
The users group is a work in progress. The user will be unable
to administer the instance via the UI. We're still auditing
all management API endpoints to ensure that they correctly
limit access to administrators.
`
: ""}
<table>
<tr>
<td>
${this.hass.localize("ui.panel.config.users.editor.id")}
</td>
<td>${user.id}</td>
</tr>
<tr>
<td>
${this.hass.localize("ui.panel.config.users.editor.owner")}
</td>
<td>${user.is_owner}</td>
</tr>
<tr>
<td>
${this.hass.localize("ui.panel.config.users.editor.active")}
</td>
<td>${user.is_active}</td>
</tr>
<tr>
<td>
${this.hass.localize(
"ui.panel.config.users.editor.system_generated"
)}
</td>
<td>${user.system_generated}</td>
</tr>
</table>
</div>
</div>
<div slot="secondaryAction">
<mwc-button
class="warning"
@click=${this._deleteEntry}
.disabled=${this._submitting || user.system_generated}
>
${this.hass!.localize("ui.panel.config.users.editor.delete_user")}
</mwc-button>
${user.system_generated
? html`
<paper-tooltip position="right"
>${this.hass.localize(
"ui.panel.config.users.editor.system_generated_users_not_removable"
)}</paper-tooltip
>
`
: ""}
</div>
<mwc-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!this._name}
>
${this.hass!.localize("ui.panel.config.users.editor.update_user")}
</mwc-button>
</ha-dialog>
`;
}
private _nameChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._name = ev.detail.value;
}
private async _handleGroupChange(ev): Promise<void> {
this._group = ev.detail.item.getAttribute("group-id");
}
private async _updateEntry() {
this._submitting = true;
try {
await this._params!.updateEntry({
name: this._name.trim(),
group_ids: [this._group!],
});
this._close();
} catch (err) {
this._error = err?.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry()) {
this._params = undefined;
}
} finally {
this._submitting = false;
}
}
private _close(): void {
this._params = undefined;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-min-width: 500px;
}
table {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-user-detail": DialogUserDetail;
}
}

View File

@ -1,166 +0,0 @@
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../layouts/hass-tabs-subpage";
import "../../../components/ha-icon-next";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import LocalizeMixin from "../../../mixins/localize-mixin";
import NavigateMixin from "../../../mixins/navigate-mixin";
import { EventsMixin } from "../../../mixins/events-mixin";
import { computeRTL } from "../../../common/util/compute_rtl";
import { configSections } from "../ha-panel-config";
let registeredDialog = false;
/*
* @appliesMixin LocalizeMixin
* @appliesMixin NavigateMixin
* @appliesMixin EventsMixin
*/
class HaUserPicker extends EventsMixin(
NavigateMixin(LocalizeMixin(PolymerElement))
) {
static get template() {
return html`
<style>
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[rtl] {
right: auto;
left: 16px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab[rtl][is-wide] {
bottom: 24px;
right: auto;
left: 24px;
}
ha-card {
max-width: 600px;
margin: 16px auto;
overflow: hidden;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
</style>
<hass-tabs-subpage
hass="[[hass]]"
narrow="[[narrow]]"
route="[[route]]"
back-path="/config"
tabs="[[_computeTabs()]]"
>
<ha-card>
<template is="dom-repeat" items="[[users]]" as="user">
<a href="[[_computeUrl(user)]]">
<paper-item>
<paper-item-body two-line>
<div>[[_withDefault(user.name, 'Unnamed User')]]</div>
<div secondary="">
[[_computeGroup(localize, user)]]
<template is="dom-if" if="[[user.system_generated]]">
-
[[localize('ui.panel.config.users.picker.system_generated')]]
</template>
</div>
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
</template>
</ha-card>
<ha-fab
is-wide$="[[isWide]]"
narrow$="[[narrow]]"
icon="hass:plus"
title="[[localize('ui.panel.config.users.picker.add_user')]]"
on-click="_addUser"
rtl$="[[rtl]]"
></ha-fab>
</hass-tabs-subpage>
`;
}
static get properties() {
return {
hass: Object,
users: Array,
isWide: Boolean,
narrow: Boolean,
route: Object,
rtl: {
type: Boolean,
reflectToAttribute: true,
computed: "_computeRTL(hass)",
},
};
}
connectedCallback() {
super.connectedCallback();
if (!registeredDialog) {
registeredDialog = true;
this.fire("register-dialog", {
dialogShowEvent: "show-add-user",
dialogTag: "ha-dialog-add-user",
dialogImport: () =>
import(
/* webpackChunkName: "ha-dialog-add-user" */ "./ha-dialog-add-user"
),
});
}
}
_withDefault(value, defaultValue) {
return value || defaultValue;
}
_computeUrl(user) {
return `/config/users/${user.id}`;
}
_computeGroup(localize, user) {
return localize(`groups.${user.group_ids[0]}`);
}
_computeRTL(hass) {
return computeRTL(hass);
}
_computeTabs() {
return configSections.persons;
}
_addUser() {
this.fire("show-add-user", {
hass: this.hass,
dialogClosedCallback: async ({ userId }) => {
this.fire("reload-users");
if (userId) this.navigate(`/config/users/${userId}`);
},
});
}
}
customElements.define("ha-config-user-picker", HaUserPicker);

View File

@ -1,109 +0,0 @@
import "@polymer/app-route/app-route";
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 NavigateMixin from "../../../mixins/navigate-mixin";
import "./ha-config-user-picker";
import "./ha-user-editor";
import { fireEvent } from "../../../common/dom/fire_event";
import { fetchUsers } from "../../../data/user";
/*
* @appliesMixin NavigateMixin
*/
class HaConfigUsers extends NavigateMixin(PolymerElement) {
static get template() {
return html`
<app-route
route="[[route]]"
pattern="/:user"
data="{{_routeData}}"
></app-route>
<template is="dom-if" if='[[_equals(_routeData.user, "picker")]]'>
<ha-config-user-picker
hass="[[hass]]"
users="[[_users]]"
is-wide="[[isWide]]"
narrow="[[narrow]]"
route="[[route]]"
></ha-config-user-picker>
</template>
<template
is="dom-if"
if='[[!_equals(_routeData.user, "picker")]]'
restamp
>
<ha-user-editor
hass="[[hass]]"
user="[[_computeUser(_users, _routeData.user)]]"
narrow="[[narrow]]"
route="[[route]]"
></ha-user-editor>
</template>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
route: {
type: Object,
observer: "_checkRoute",
},
_routeData: Object,
_user: {
type: Object,
value: null,
},
_users: {
type: Array,
value: null,
},
};
}
ready() {
super.ready();
this._loadData();
this.addEventListener("reload-users", () => this._loadData());
}
_handlePickUser(ev) {
this._user = ev.detail.user;
}
_checkRoute(route) {
// prevent list getting under toolbar
fireEvent(this, "iron-resize");
this._debouncer = Debouncer.debounce(
this._debouncer,
timeOut.after(0),
() => {
if (route.path === "") {
this.navigate(`${route.prefix}/picker`, true);
}
}
);
}
_computeUser(users, userId) {
return users && users.filter((u) => u.id === userId)[0];
}
_equals(a, b) {
return a === b;
}
async _loadData() {
this._users = await fetchUsers(this.hass);
}
}
customElements.define("ha-config-users", HaConfigUsers);

View File

@ -0,0 +1,192 @@
import "../../../layouts/hass-tabs-subpage-data-table";
import "../../../components/ha-fab";
import { computeRTL } from "../../../common/util/compute_rtl";
import { configSections } from "../ha-panel-config";
import {
LitElement,
property,
css,
PropertyValues,
customElement,
} from "lit-element";
import { HomeAssistant, Route } from "../../../types";
import { html } from "lit-html";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { User, fetchUsers, updateUser, deleteUser } from "../../../data/user";
import memoizeOne from "memoize-one";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import { showUserDetailDialog } from "./show-dialog-user-detail";
import { showAddUserDialog } from "./show-dialog-add-user";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-config-users")
export class HaConfigUsers extends LitElement {
@property() public hass!: HomeAssistant;
@property() public _users: User[] = [];
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
private _columns = memoizeOne(
(_language): DataTableColumnContainer => {
return {
name: {
title: this.hass.localize(
"ui.panel.config.users.picker.headers.name"
),
sortable: true,
filterable: true,
direction: "asc",
grows: true,
template: (name) => html`
${name ||
this.hass!.localize("ui.panel.config.users.editor.unnamed_user")}
`,
},
group_ids: {
title: this.hass.localize(
"ui.panel.config.users.picker.headers.group"
),
sortable: true,
filterable: true,
width: "25%",
template: (groupIds) => html`
${this.hass.localize(`groups.${groupIds[0]}`)}
`,
},
system_generated: {
title: this.hass.localize(
"ui.panel.config.users.picker.headers.system"
),
type: "icon",
width: "10%",
sortable: true,
filterable: true,
template: (generated) => html`
${generated
? html`
<ha-icon icon="hass:check-circle-outline"></ha-icon>
`
: ""}
`,
},
};
}
);
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._fetchUsers();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
backPath="/config"
.tabs=${configSections.persons}
.columns=${this._columns(this.hass.language)}
.data=${this._users}
@row-click=${this._editUser}
>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
.title=${this.hass.localize("ui.panel.config.users.picker.add_user")}
@click=${this._addUser}
?rtl=${computeRTL(this.hass)}
></ha-fab>
`;
}
private async _fetchUsers() {
this._users = await fetchUsers(this.hass);
}
private _editUser(ev: HASSDomEvent<RowClickedEvent>) {
const id = ev.detail.id;
const entry = this._users.find((user) => user.id === id);
if (!entry) {
return;
}
showUserDetailDialog(this, {
entry,
updateEntry: async (values) => {
const updated = await updateUser(this.hass!, entry!.id, values);
this._users = this._users!.map((ent) =>
ent === entry ? updated.user : ent
);
},
removeEntry: async () => {
if (
!(await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.users.editor.confirm_user_deletion",
"name",
entry.name
),
dismissText: this.hass!.localize("ui.common.no"),
confirmText: this.hass!.localize("ui.common.yes"),
}))
) {
return false;
}
try {
await deleteUser(this.hass!, entry!.id);
this._users = this._users!.filter((ent) => ent !== entry);
return true;
} catch (err) {
return false;
}
},
});
}
private _addUser() {
showAddUserDialog(this, {
userAddedCallback: async (user: User) => {
if (user) {
this._users = { ...this._users, ...user };
}
},
});
}
static get styles() {
return css`
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[rtl] {
right: auto;
left: 16px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab[rtl][is-wide] {
bottom: 24px;
right: auto;
left: 24px;
}
`;
}
}

View File

@ -1,201 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-spinner/paper-spinner";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../components/dialog/ha-paper-dialog";
import "../../../resources/ha-style";
import LocalizeMixin from "../../../mixins/localize-mixin";
/*
* @appliesMixin LocalizeMixin
*/
class HaDialogAddUser extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style include="ha-style-dialog">
.error {
color: red;
}
ha-paper-dialog {
max-width: 500px;
}
.username {
margin-top: -8px;
}
</style>
<ha-paper-dialog
id="dialog"
with-backdrop
opened="{{_opened}}"
on-opened-changed="_openedChanged"
>
<h2>[[localize('ui.panel.config.users.add_user.caption')]]</h2>
<div>
<template is="dom-if" if="[[_errorMsg]]">
<div class="error">[[_errorMsg]]</div>
</template>
<paper-input
class="name"
label="[[localize('ui.panel.config.users.add_user.name')]]"
value="{{_name}}"
required
auto-validate
autocapitalize="on"
error-message="Required"
on-blur="_maybePopulateUsername"
></paper-input>
<paper-input
class="username"
label="[[localize('ui.panel.config.users.add_user.username')]]"
value="{{_username}}"
required
auto-validate
autocapitalize="none"
error-message="Required"
></paper-input>
<paper-input
label="[[localize('ui.panel.config.users.add_user.password')]]"
type="password"
value="{{_password}}"
required
auto-validate
error-message="Required"
></paper-input>
</div>
<div class="buttons">
<template is="dom-if" if="[[_loading]]">
<div class="submit-spinner">
<paper-spinner active></paper-spinner>
</div>
</template>
<template is="dom-if" if="[[!_loading]]">
<mwc-button on-click="_createUser"
>[[localize('ui.panel.config.users.add_user.create')]]</mwc-button
>
</template>
</div>
</ha-paper-dialog>
`;
}
static get properties() {
return {
_hass: Object,
_dialogClosedCallback: Function,
_loading: {
type: Boolean,
value: false,
},
// Error message when can't talk to server etc
_errorMsg: String,
_opened: {
type: Boolean,
value: false,
},
_name: String,
_username: String,
_password: String,
};
}
ready() {
super.ready();
this.addEventListener("keypress", (ev) => {
if (ev.keyCode === 13) {
this._createUser(ev);
}
});
}
showDialog({ hass, dialogClosedCallback }) {
this.hass = hass;
this._dialogClosedCallback = dialogClosedCallback;
this._loading = false;
this._opened = true;
setTimeout(() => this.shadowRoot.querySelector("paper-input").focus(), 0);
}
_maybePopulateUsername() {
if (this._username) return;
const parts = this._name.split(" ");
if (parts.length) {
this._username = parts[0].toLowerCase();
}
}
async _createUser(ev) {
ev.preventDefault();
if (!this._name || !this._username || !this._password) return;
this._loading = true;
this._errorMsg = null;
let userId;
try {
const userResponse = await this.hass.callWS({
type: "config/auth/create",
name: this._name,
});
userId = userResponse.user.id;
} catch (err) {
this._loading = false;
this._errorMsg = err.code;
return;
}
try {
await this.hass.callWS({
type: "config/auth_provider/homeassistant/create",
user_id: userId,
username: this._username,
password: this._password,
});
} catch (err) {
this._loading = false;
this._errorMsg = err.code;
await this.hass.callWS({
type: "config/auth/delete",
user_id: userId,
});
return;
}
this._dialogDone(userId);
}
_dialogDone(userId) {
this._dialogClosedCallback({ userId });
this.setProperties({
_errorMsg: null,
_username: "",
_password: "",
_dialogClosedCallback: null,
_opened: false,
});
}
_equals(a, b) {
return a === b;
}
_openedChanged(ev) {
// Closed dialog by clicking on the overlay
// Check against dialogClosedCallback to make sure we didn't change
// programmatically
if (this._dialogClosedCallback && !ev.detail.value) {
this._dialogDone();
}
}
}
customElements.define("ha-dialog-add-user", HaDialogAddUser);

View File

@ -1,257 +0,0 @@
import {
LitElement,
TemplateResult,
html,
customElement,
CSSResultArray,
css,
property,
} from "lit-element";
import { until } from "lit-html/directives/until";
import "@material/mwc-button";
import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import "../../../components/ha-card";
import { HomeAssistant, Route } from "../../../types";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import {
User,
deleteUser,
updateUser,
SYSTEM_GROUP_ID_USER,
SYSTEM_GROUP_ID_ADMIN,
} from "../../../data/user";
import { showSaveSuccessToast } from "../../../util/toast-saved-success";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { configSections } from "../ha-panel-config";
declare global {
interface HASSDomEvents {
"reload-users": undefined;
}
}
const GROUPS = [SYSTEM_GROUP_ID_USER, SYSTEM_GROUP_ID_ADMIN];
@customElement("ha-user-editor")
class HaUserEditor extends LitElement {
@property() public hass?: HomeAssistant;
@property() public user?: User;
@property() public narrow?: boolean;
@property() public route!: Route;
protected render(): TemplateResult {
const hass = this.hass;
const user = this.user;
if (!hass || !user) {
return html``;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${configSections.persons}
>
<ha-card .header=${this._name}>
<table class="card-content">
<tr>
<td>${hass.localize("ui.panel.config.users.editor.id")}</td>
<td>${user.id}</td>
</tr>
<tr>
<td>${hass.localize("ui.panel.config.users.editor.owner")}</td>
<td>${user.is_owner}</td>
</tr>
<tr>
<td>${hass.localize("ui.panel.config.users.editor.group")}</td>
<td>
<select
@change=${this._handleGroupChange}
.value=${until(
this.updateComplete.then(() => user.group_ids[0])
)}
>
${GROUPS.map(
(groupId) => html`
<option value=${groupId}>
${hass.localize(`groups.${groupId}`)}
</option>
`
)}
</select>
</td>
</tr>
${user.group_ids[0] === SYSTEM_GROUP_ID_USER
? html`
<tr>
<td colspan="2" class="user-experiment">
The users group is a work in progress. The user will be
unable to administer the instance via the UI. We're still
auditing all management API endpoints to ensure that they
correctly limit access to administrators.
</td>
</tr>
`
: ""}
<tr>
<td>${hass.localize("ui.panel.config.users.editor.active")}</td>
<td>${user.is_active}</td>
</tr>
<tr>
<td>
${hass.localize(
"ui.panel.config.users.editor.system_generated"
)}
</td>
<td>${user.system_generated}</td>
</tr>
</table>
<div class="card-actions">
<mwc-button @click=${this._handlePromptRenameUser}>
${hass.localize("ui.panel.config.users.editor.rename_user")}
</mwc-button>
<mwc-button
class="warning"
@click=${this._promptDeleteUser}
.disabled=${user.system_generated}
>
${hass.localize("ui.panel.config.users.editor.delete_user")}
</mwc-button>
${user.system_generated
? html`
${hass.localize(
"ui.panel.config.users.editor.system_generated_users_not_removable"
)}
`
: ""}
</div>
</ha-card>
</hass-tabs-subpage>
`;
}
private get _name() {
return (
this.user &&
(this.user.name ||
this.hass!.localize("ui.panel.config.users.editor.unnamed_user"))
);
}
private async _handleRenameUser(newName?: string) {
if (newName === null || newName === this.user!.name) {
return;
}
try {
await updateUser(this.hass!, this.user!.id, {
name: newName,
});
fireEvent(this, "reload-users");
} catch (err) {
showAlertDialog(this, {
text: `${this.hass!.localize(
"ui.panel.config.users.editor.user_rename_failed"
)} ${err.message}`,
});
}
}
private async _handlePromptRenameUser(ev): Promise<void> {
ev.currentTarget.blur();
showPromptDialog(this, {
title: this.hass!.localize("ui.panel.config.users.editor.enter_new_name"),
defaultValue: this.user!.name,
inputLabel: this.hass!.localize("ui.panel.config.users.add_user.name"),
confirm: (text) => this._handleRenameUser(text),
});
}
private async _handleGroupChange(ev): Promise<void> {
const selectEl = ev.currentTarget as HTMLSelectElement;
const newGroup = selectEl.value;
try {
await updateUser(this.hass!, this.user!.id, {
group_ids: [newGroup],
});
showSaveSuccessToast(this, this.hass!);
fireEvent(this, "reload-users");
} catch (err) {
showAlertDialog(this, {
text: `${this.hass!.localize(
"ui.panel.config.users.editor.group_update_failed"
)} ${err.message}`,
});
selectEl.value = this.user!.group_ids[0];
}
}
private async _deleteUser() {
try {
await deleteUser(this.hass!, this.user!.id);
} catch (err) {
showAlertDialog(this, {
text: err.code,
});
return;
}
fireEvent(this, "reload-users");
navigate(this, "/config/users");
}
private async _promptDeleteUser(_ev): Promise<void> {
showConfirmationDialog(this, {
text: this.hass!.localize(
"ui.panel.config.users.editor.confirm_user_deletion",
"name",
this._name
),
confirm: () => this._deleteUser(),
});
}
static get styles(): CSSResultArray {
return [
haStyle,
css`
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
ha-card {
max-width: 600px;
margin: 16px auto 16px;
}
hass-subpage ha-card:first-of-type {
direction: ltr;
}
table {
width: 100%;
}
td {
vertical-align: top;
}
.user-experiment {
padding: 8px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-user-editor": HaUserEditor;
}
}

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { User } from "../../../data/user";
export interface AddUserDialogParams {
userAddedCallback: (user: User) => void;
}
export const loadAddUserDialog = () =>
import(/* webpackChunkName: "add-user-dialog" */ "./dialog-add-user");
export const showAddUserDialog = (
element: HTMLElement,
dialogParams: AddUserDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-add-user",
dialogImport: loadAddUserDialog,
dialogParams,
});
};

View File

@ -0,0 +1,22 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { User, UpdateUserParams } from "../../../data/user";
export interface UserDetailDialogParams {
entry: User;
updateEntry: (updates: Partial<UpdateUserParams>) => Promise<unknown>;
removeEntry: () => Promise<boolean>;
}
export const loadUserDetailDialog = () =>
import(/* webpackChunkName: "user-detail-dialog" */ "./dialog-user-detail");
export const showUserDetailDialog = (
element: HTMLElement,
detailParams: UserDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-user-detail",
dialogImport: loadUserDetailDialog,
dialogParams: detailParams,
});
};

View File

@ -12,6 +12,7 @@ class HaPanelIframe extends PolymerElement {
iframe { iframe {
border: 0; border: 0;
width: 100%; width: 100%;
position: absolute;
height: calc(100% - 64px); height: calc(100% - 64px);
background-color: var(--primary-background-color); background-color: var(--primary-background-color);
} }

View File

@ -25,7 +25,7 @@ import { AlarmPanelCardConfig } from "./types";
import { PaperInputElement } from "@polymer/paper-input/paper-input"; import { PaperInputElement } from "@polymer/paper-input/paper-input";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
import { LovelaceConfig } from "../../../data/lovelace"; import { fireEvent } from "../../../common/dom/fire_event";
const ICONS = { const ICONS = {
armed_away: "hass:shield-lock", armed_away: "hass:shield-lock",
@ -50,22 +50,21 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): AlarmPanelCardConfig {
) {
const includeDomains = ["alarm_control_panel"]; const includeDomains = ["alarm_control_panel"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { return {
type: "alarm-panel",
states: ["arm_home", "arm_away"], states: ["arm_home", "arm_away"],
entity: foundEntities[0] || "", entity: foundEntities[0] || "",
}; };
@ -173,6 +172,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
class="${classMap({ [stateObj.state]: true })}" class="${classMap({ [stateObj.state]: true })}"
.icon="${ICONS[stateObj.state] || "hass:shield-outline"}" .icon="${ICONS[stateObj.state] || "hass:shield-outline"}"
.label="${this._stateIconLabel(stateObj.state)}" .label="${this._stateIconLabel(stateObj.state)}"
@click=${this._handleMoreInfo}
></ha-label-badge> ></ha-label-badge>
<div id="armActions" class="actions"> <div id="armActions" class="actions">
${(stateObj.state === "disarmed" ${(stateObj.state === "disarmed"
@ -248,16 +248,22 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
} }
private _handleActionClick(e: MouseEvent): void { private _handleActionClick(e: MouseEvent): void {
const input = this._input!; const input = this._input;
const code =
input && input.value && input.value.length > 0 ? input.value : "";
callAlarmAction( callAlarmAction(
this.hass!, this.hass!,
this._config!.entity, this._config!.entity,
(e.currentTarget! as any).action, (e.currentTarget! as any).action,
code input?.value || undefined
); );
input.value = ""; if (input) {
input.value = "";
}
}
private _handleMoreInfo() {
fireEvent(this, "hass-more-info", {
entityId: this._config!.entity,
});
} }
static get styles(): CSSResult { static get styles(): CSSResult {
@ -281,6 +287,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
position: absolute; position: absolute;
right: 12px; right: 12px;
top: 12px; top: 12px;
cursor: pointer;
} }
.disarmed { .disarmed {

View File

@ -30,7 +30,7 @@ import { ButtonCardConfig } from "./types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { hasAction } from "../common/has-action"; import { hasAction } from "../common/has-action";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { ActionHandlerEvent, LovelaceConfig } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { computeActiveState } from "../../../common/entity/compute_active_state"; import { computeActiveState } from "../../../common/entity/compute_active_state";
import { iconColorCSS } from "../../../common/style/icon_color_css"; import { iconColorCSS } from "../../../common/style/icon_color_css";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@ -46,26 +46,24 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): ButtonCardConfig {
): object {
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
["light", "switch"] ["light", "switch"]
); );
return { return {
type: "button",
tap_action: { action: "toggle" }, tap_action: { action: "toggle" },
hold_action: { action: "more-info" }, hold_action: { action: "more-info" },
show_icon: true, show_icon: true,
show_name: true, show_name: true,
state_color: true,
entity: foundEntities[0] || "", entity: foundEntities[0] || "",
}; };
} }
@ -230,6 +228,9 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
text-align: center; text-align: center;
padding: 4% 0; padding: 4% 0;
font-size: 1.2rem; font-size: 1.2rem;
height: 100%;
box-sizing: border-box;
justify-content: center;
} }
ha-card:focus { ha-card:focus {

View File

@ -15,10 +15,11 @@ class HuiConditionalCard extends HuiConditionalBase implements LovelaceCard {
return document.createElement("hui-conditional-card-editor"); return document.createElement("hui-conditional-card-editor");
} }
public static getStubConfig(): object { public static getStubConfig(): ConditionalCardConfig {
return { return {
type: "conditional",
conditions: [], conditions: [],
card: {}, card: { type: "" },
}; };
} }

View File

@ -27,7 +27,6 @@ import { createHeaderFooterElement } from "../create-element/create-header-foote
import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { DOMAINS_TOGGLE } from "../../../common/const"; import { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@customElement("hui-entities-card") @customElement("hui-entities-card")
@ -41,21 +40,19 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): EntitiesCardConfig {
) {
const maxEntities = 3; const maxEntities = 3;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
["light", "switch", "sensor"] ["light", "switch", "sensor"]
); );
return { title: "My Title", entities: foundEntities }; return { type: "entities", title: "My Title", entities: foundEntities };
} }
@property() private _config?: EntitiesCardConfig; @property() private _config?: EntitiesCardConfig;
@ -188,6 +185,9 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
ha-card {
height: 100%;
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -22,7 +22,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { hasConfigOrEntityChanged } from "../common/has-changed"; import { hasConfigOrEntityChanged } from "../common/has-changed";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { GaugeCardConfig } from "./types"; import { GaugeCardConfig } from "./types";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
import { HassEntity } from "home-assistant-js-websocket/dist/types"; import { HassEntity } from "home-assistant-js-websocket/dist/types";
@ -44,10 +43,9 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): GaugeCardConfig {
): object {
const includeDomains = ["sensor"]; const includeDomains = ["sensor"];
const maxEntities = 1; const maxEntities = 1;
const entityFilter = (stateObj: HassEntity): boolean => { const entityFilter = (stateObj: HassEntity): boolean => {
@ -56,15 +54,14 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains, includeDomains,
entityFilter entityFilter
); );
return { entity: foundEntities[0] || "" }; return { type: "gauge", entity: foundEntities[0] || "" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;
@ -136,7 +133,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
> >
<div class="container"> <div class="container">
<div class="gauge-a"></div> <div class="gauge-a"></div>
<div class="gauge-b"></div>
<div <div
class="gauge-c" class="gauge-c"
style=${styleMap({ style=${styleMap({
@ -144,16 +140,17 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
"background-color": this._computeSeverity(state), "background-color": this._computeSeverity(state),
})} })}
></div> ></div>
<div class="gauge-data"> <div class="gauge-b"></div>
<div id="percent"> </div>
${stateObj.state} <div class="gauge-data">
${this._config.unit || <div id="percent">
stateObj.attributes.unit_of_measurement || ${stateObj.state}
""} ${this._config.unit ||
</div> stateObj.attributes.unit_of_measurement ||
<div id="name"> ""}
${this._config.name || computeStateName(stateObj)} </div>
</div> <div id="name">
${this._config.name || computeStateName(stateObj)}
</div> </div>
</div> </div>
</ha-card> </ha-card>
@ -250,9 +247,14 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
ha-card { ha-card {
height: calc(var(--base-unit) * 3);
position: relative;
cursor: pointer; cursor: pointer;
padding: 16px 16px 0 16px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
justify-content: center;
align-items: center;
} }
ha-card:focus { ha-card:focus {
outline: none; outline: none;
@ -261,15 +263,10 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
.container { .container {
width: calc(var(--base-unit) * 4); width: calc(var(--base-unit) * 4);
height: calc(var(--base-unit) * 2); height: calc(var(--base-unit) * 2);
position: absolute;
top: calc(var(--base-unit) * 1.5);
left: 50%;
overflow: hidden; overflow: hidden;
text-align: center; position: relative;
transform: translate(-50%, -50%);
} }
.gauge-a { .gauge-a {
z-index: 1;
position: absolute; position: absolute;
background-color: var(--primary-background-color); background-color: var(--primary-background-color);
width: calc(var(--base-unit) * 4); width: calc(var(--base-unit) * 4);
@ -279,7 +276,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
0px 0px; 0px 0px;
} }
.gauge-b { .gauge-b {
z-index: 3;
position: absolute; position: absolute;
background-color: var(--paper-card-background-color); background-color: var(--paper-card-background-color);
width: calc(var(--base-unit) * 2.5); width: calc(var(--base-unit) * 2.5);
@ -291,7 +287,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
0px 0px; 0px 0px;
} }
.gauge-c { .gauge-c {
z-index: 2;
position: absolute; position: absolute;
background-color: var(--label-badge-blue); background-color: var(--label-badge-blue);
width: calc(var(--base-unit) * 4); width: calc(var(--base-unit) * 4);
@ -307,15 +302,12 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
transition: all 1.3s ease-in-out; transition: all 1.3s ease-in-out;
} }
.gauge-data { .gauge-data {
z-index: 4; text-align: center;
color: var(--primary-text-color); color: var(--primary-text-color);
line-height: calc(var(--base-unit) * 0.3); line-height: calc(var(--base-unit) * 0.3);
position: absolute; width: 100%;
width: calc(var(--base-unit) * 4); position: relative;
height: var(--base-unit); top: calc(var(--base-unit) * -0.5);
top: var(--base-unit);
margin-left: auto;
margin-right: auto;
} }
.init .gauge-data { .init .gauge-data {
transition: all 1s ease-out; transition: all 1s ease-out;

View File

@ -27,7 +27,7 @@ import { processConfigEntities } from "../common/process-config-entities";
import { GlanceCardConfig, GlanceConfigEntity } from "./types"; import { GlanceCardConfig, GlanceConfigEntity } from "./types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { hasAction } from "../common/has-action"; import { hasAction } from "../common/has-action";
import { ActionHandlerEvent, LovelaceConfig } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity"; import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
@ -44,18 +44,16 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[]
): GlanceCardConfig { ): GlanceCardConfig {
const includeDomains = ["sensor"]; const includeDomains = ["sensor"];
const maxEntities = 3; const maxEntities = 3;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );

View File

@ -20,7 +20,6 @@ import { HomeAssistant } from "../../../types";
import { HistoryGraphCardConfig } from "./types"; import { HistoryGraphCardConfig } from "./types";
import { LovelaceCard } from "../types"; import { LovelaceCard } from "../types";
import { EntityConfig } from "../entity-rows/types"; import { EntityConfig } from "../entity-rows/types";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@customElement("hui-history-graph-card") @customElement("hui-history-graph-card")
@ -34,22 +33,20 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): HistoryGraphCardConfig {
): object {
const includeDomains = ["sensor"]; const includeDomains = ["sensor"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entities: foundEntities }; return { type: "history-graph", entities: foundEntities };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;

View File

@ -13,6 +13,7 @@ import "../../../components/ha-card";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { styleMap } from "lit-html/directives/style-map"; import { styleMap } from "lit-html/directives/style-map";
import { IframeCardConfig } from "./types"; import { IframeCardConfig } from "./types";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
@customElement("hui-iframe-card") @customElement("hui-iframe-card")
export class HuiIframeCard extends LitElement implements LovelaceCard { export class HuiIframeCard extends LitElement implements LovelaceCard {
@ -22,10 +23,17 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
); );
return document.createElement("hui-iframe-card-editor"); return document.createElement("hui-iframe-card-editor");
} }
public static getStubConfig(): object { public static getStubConfig(): IframeCardConfig {
return { url: "https://www.home-assistant.io", aspect_ratio: "50%" }; return {
type: "iframe",
url: "https://www.home-assistant.io",
aspect_ratio: "50%",
};
} }
@property({ type: Boolean, reflect: true })
public isPanel = false;
@property({ type: Boolean, reflect: true })
public editMode = false;
@property() protected _config?: IframeCardConfig; @property() protected _config?: IframeCardConfig;
public getCardSize(): number { public getCardSize(): number {
@ -51,17 +59,29 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
return html``; return html``;
} }
const aspectRatio = this._config.aspect_ratio || "50%"; let padding = "";
if (!this.isPanel && this._config.aspect_ratio) {
const ratio = parseAspectRatio(this._config.aspect_ratio);
if (ratio && ratio.w > 0 && ratio.h > 0) {
padding = `${((100 * ratio.h) / ratio.w).toFixed(2)}%`;
}
} else if (!this.isPanel) {
padding = "50%";
}
return html` return html`
<ha-card .header="${this._config.title}"> <ha-card .header="${this._config.title}">
<div <div
id="root" id="root"
style="${styleMap({ style="${styleMap({
"padding-top": aspectRatio, "padding-top": padding,
})}" })}"
> >
<iframe src="${this._config.url}"></iframe> <iframe
src="${this._config.url}"
sandbox="allow-forms allow-modals allow-popups allow-pointer-lock allow-same-origin allow-scripts"
allowfullscreen="true"
></iframe>
</div> </div>
</ha-card> </ha-card>
`; `;
@ -69,6 +89,15 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
:host([ispanel]) ha-card {
width: 100%;
height: 100%;
}
:host([ispanel][editMode]) ha-card {
height: calc(100% - 51px);
}
ha-card { ha-card {
overflow: hidden; overflow: hidden;
} }
@ -78,6 +107,10 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
position: relative; position: relative;
} }
:host([ispanel]) #root {
height: 100%;
}
iframe { iframe {
position: absolute; position: absolute;
border: none; border: none;

View File

@ -29,7 +29,6 @@ import { toggleEntity } from "../common/entity/toggle-entity";
import { LightCardConfig } from "./types"; import { LightCardConfig } from "./types";
import { supportsFeature } from "../../../common/entity/supports-feature"; import { supportsFeature } from "../../../common/entity/supports-feature";
import { SUPPORT_BRIGHTNESS } from "../../../data/light"; import { SUPPORT_BRIGHTNESS } from "../../../data/light";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
import { UNAVAILABLE } from "../../../data/entity"; import { UNAVAILABLE } from "../../../data/entity";
@ -44,22 +43,20 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): LightCardConfig {
): object {
const includeDomains = ["light"]; const includeDomains = ["light"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entity: foundEntities[0] || "" }; return { type: "light", entity: foundEntities[0] || "" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;
@ -119,40 +116,45 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
tabindex="0" tabindex="0"
></paper-icon-button> ></paper-icon-button>
<div id="controls"> <div class="content">
<div id="slider"> <div id="controls">
<round-slider <div id="slider">
min="0" ${supportsFeature(stateObj, SUPPORT_BRIGHTNESS)
.value=${brightness} ? html`
@value-changing=${this._dragEvent} <round-slider
@value-changed=${this._setBrightness} min="0"
style=${styleMap({ .value=${brightness}
visibility: supportsFeature(stateObj, SUPPORT_BRIGHTNESS) @value-changing=${this._dragEvent}
? "visible" @value-changed=${this._setBrightness}
: "hidden", ></round-slider>
})} `
></round-slider> : ""}
<paper-icon-button <paper-icon-button
class="light-button ${classMap({ class="light-button ${classMap({
"state-on": stateObj.state === "on", "slider-center": supportsFeature(
"state-unavailable": stateObj.state === "unavailable", stateObj,
})}" SUPPORT_BRIGHTNESS
.icon=${this._config.icon || stateIcon(stateObj)} ),
style=${styleMap({ "state-on": stateObj.state === "on",
filter: this._computeBrightness(stateObj), "state-unavailable": stateObj.state === "unavailable",
color: this._computeColor(stateObj), })}"
})} .icon=${this._config.icon || stateIcon(stateObj)}
@click=${this._handleClick} style=${styleMap({
tabindex="0" filter: this._computeBrightness(stateObj),
></paper-icon-button> color: this._computeColor(stateObj),
})}
@click=${this._handleClick}
tabindex="0"
></paper-icon-button>
</div>
</div> </div>
</div>
<div id="info"> <div id="info">
<div class="brightness"> <div class="brightness">
% %
</div>
${this._config.name || computeStateName(stateObj)}
</div> </div>
${this._config.name || computeStateName(stateObj)}
</div> </div>
</ha-card> </ha-card>
`; `;
@ -259,6 +261,8 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
} }
ha-card { ha-card {
height: 100%;
box-sizing: border-box;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
@ -276,6 +280,13 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
z-index: 25; z-index: 25;
} }
.content {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
#controls { #controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -301,13 +312,6 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
color: var(--paper-item-icon-color, #44739e); color: var(--paper-item-icon-color, #44739e);
width: 60%; width: 60%;
height: auto; height: auto;
position: absolute;
max-width: calc(100% - 40px);
box-sizing: border-box;
border-radius: 100%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
} }
.light-button.state-on { .light-button.state-on {
@ -318,9 +322,17 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
color: var(--state-icon-unavailable-color); color: var(--state-icon-unavailable-color);
} }
.slider-center {
position: absolute;
max-width: calc(100% - 40px);
box-sizing: border-box;
border-radius: 100%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#info { #info {
display: flex-vertical;
justify-content: center;
text-align: center; text-align: center;
margin-top: -56px; margin-top: -56px;
padding: 16px; padding: 16px;

View File

@ -30,7 +30,6 @@ import { EntityConfig } from "../entity-rows/types";
import { processConfigEntities } from "../common/process-config-entities"; import { processConfigEntities } from "../common/process-config-entities";
import { MapCardConfig } from "./types"; import { MapCardConfig } from "./types";
import { classMap } from "lit-html/directives/class-map"; import { classMap } from "lit-html/directives/class-map";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@customElement("hui-map-card") @customElement("hui-map-card")
@ -44,28 +43,28 @@ class HuiMapCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): MapCardConfig {
): object {
const includeDomains = ["device_tracker"]; const includeDomains = ["device_tracker"];
const maxEntities = 2; const maxEntities = 2;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entities: foundEntities }; return { type: "map", entities: foundEntities };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
public isPanel = false; public isPanel = false;
@property({ type: Boolean, reflect: true })
public editMode = false;
@property() @property()
private _config?: MapCardConfig; private _config?: MapCardConfig;
@ -116,9 +115,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
} }
public getCardSize(): number { public getCardSize(): number {
if (!this._config) { if (!this._config?.aspect_ratio) {
return 3; return 5;
} }
const ratio = parseAspectRatio(this._config.aspect_ratio); const ratio = parseAspectRatio(this._config.aspect_ratio);
const ar = const ar =
ratio && ratio.w > 0 && ratio.h > 0 ratio && ratio.w > 0 && ratio.h > 0
@ -209,6 +209,11 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._attachObserver(); this._attachObserver();
} }
if (!this._config.aspect_ratio) {
root.style.paddingBottom = "100%";
return;
}
const ratio = parseAspectRatio(this._config.aspect_ratio); const ratio = parseAspectRatio(this._config.aspect_ratio);
root.style.paddingBottom = root.style.paddingBottom =
@ -453,15 +458,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
:host([ispanel]) ha-card { :host([ispanel]) ha-card {
left: 0;
top: 0;
width: 100%; width: 100%;
/**
* In panel mode we want a full height map. Since parent #view
* only sets min-height, we need absolute positioning here
*/
height: 100%; height: 100%;
position: absolute; }
:host([ispanel][editMode]) ha-card {
height: calc(100% - 51px);
} }
ha-card { ha-card {

View File

@ -29,8 +29,9 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
return document.createElement("hui-markdown-card-editor"); return document.createElement("hui-markdown-card-editor");
} }
public static getStubConfig(): object { public static getStubConfig(): MarkdownCardConfig {
return { return {
type: "markdown",
content: content:
"The **Markdown** card allows you to write any text. You can style it **bold**, *italicized*, ~strikethrough~ etc. You can do images, links, and more.\n\nFor more information see the [Markdown Cheatsheet](https://commonmark.org/help).", "The **Markdown** card allows you to write any text. You can style it **bold**, *italicized*, ~strikethrough~ etc. You can do images, links, and more.\n\nFor more information see the [Markdown Cheatsheet](https://commonmark.org/help).",
}; };

View File

@ -30,7 +30,6 @@ import { stateIcon } from "../../../common/entity/state_icon";
import { hasConfigOrEntityChanged } from "../common/has-changed"; import { hasConfigOrEntityChanged } from "../common/has-changed";
import { contrast } from "../common/color/contrast"; import { contrast } from "../common/color/contrast";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
import { LovelaceConfig } from "../../../data/lovelace";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity"; import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
import { import {
SUPPORT_PAUSE, SUPPORT_PAUSE,
@ -176,22 +175,20 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): MediaControlCardConfig {
): object {
const includeDomains = ["media_player"]; const includeDomains = ["media_player"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entity: foundEntities[0] || "" }; return { type: "media-control", entity: foundEntities[0] || "" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;
@ -694,8 +691,10 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
return; return;
} }
Vibrant.from(this._image) new Vibrant(this._image, {
.useGenerator(customGenerator) colorCount: 16,
generator: customGenerator,
})
.getPalette() .getPalette()
.then(([foreground, background]: [string, string]) => { .then(([foreground, background]: [string, string]) => {
this._backgroundColor = background; this._backgroundColor = background;

View File

@ -30,8 +30,9 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
); );
return document.createElement("hui-picture-card-editor"); return document.createElement("hui-picture-card-editor");
} }
public static getStubConfig(): object { public static getStubConfig(): PictureCardConfig {
return { return {
type: "picture",
image: "https://demo.home-assistant.io/stub_config/t-shirt-promo.png", image: "https://demo.home-assistant.io/stub_config/t-shirt-promo.png",
tap_action: { action: "none" }, tap_action: { action: "none" },
hold_action: { action: "none" }, hold_action: { action: "none" },

View File

@ -15,24 +15,21 @@ import { HomeAssistant } from "../../../types";
import { LovelaceElementConfig, LovelaceElement } from "../elements/types"; import { LovelaceElementConfig, LovelaceElement } from "../elements/types";
import { PictureElementsCardConfig } from "./types"; import { PictureElementsCardConfig } from "./types";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@customElement("hui-picture-elements-card") @customElement("hui-picture-elements-card")
class HuiPictureElementsCard extends LitElement implements LovelaceCard { class HuiPictureElementsCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[]
): PictureElementsCardConfig { ): PictureElementsCardConfig {
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
["sensor", "binary_sensor"] ["sensor", "binary_sensor"]
); );

View File

@ -27,7 +27,7 @@ import { PictureEntityCardConfig } from "./types";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { hasAction } from "../common/has-action"; import { hasAction } from "../common/has-action";
import { ActionHandlerEvent, LovelaceConfig } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@ -42,21 +42,20 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): PictureEntityCardConfig {
): object {
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
["light", "switch"] ["light", "switch"]
); );
return { return {
type: "picture-entity",
entity: foundEntities[0] || "", entity: foundEntities[0] || "",
image: "https://demo.home-assistant.io/stub_config/bedroom.png", image: "https://demo.home-assistant.io/stub_config/bedroom.png",
}; };

View File

@ -29,7 +29,7 @@ import { hasConfigOrEntityChanged } from "../common/has-changed";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { hasAction } from "../common/has-action"; import { hasAction } from "../common/has-action";
import { ActionHandlerEvent, LovelaceConfig } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
@ -46,17 +46,15 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[]
): PictureGlanceCardConfig { ): PictureGlanceCardConfig {
const maxEntities = 2; const maxEntities = 2;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
["sensor", "binary_sensor"] ["sensor", "binary_sensor"]
); );

View File

@ -22,7 +22,6 @@ import { hasConfigOrEntityChanged } from "../common/has-changed";
import { PlantStatusCardConfig, PlantAttributeTarget } from "./types"; import { PlantStatusCardConfig, PlantAttributeTarget } from "./types";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
const SENSORS = { const SENSORS = {
@ -44,22 +43,20 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): PlantStatusCardConfig {
): object {
const includeDomains = ["plant"]; const includeDomains = ["plant"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entity: foundEntities[0] || "" }; return { type: "plant-status", entity: foundEntities[0] || "" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;

View File

@ -26,7 +26,6 @@ import { fetchRecent } from "../../../data/history";
import { SensorCardConfig } from "./types"; import { SensorCardConfig } from "./types";
import { hasConfigOrEntityChanged } from "../common/has-changed"; import { hasConfigOrEntityChanged } from "../common/has-changed";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
import { HassEntity } from "home-assistant-js-websocket/dist/types"; import { HassEntity } from "home-assistant-js-websocket/dist/types";
@ -179,10 +178,9 @@ class HuiSensorCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): SensorCardConfig {
): object {
const includeDomains = ["sensor"]; const includeDomains = ["sensor"];
const maxEntities = 1; const maxEntities = 1;
const entityFilter = (stateObj: HassEntity): boolean => { const entityFilter = (stateObj: HassEntity): boolean => {
@ -194,15 +192,14 @@ class HuiSensorCard extends LitElement implements LovelaceCard {
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains, includeDomains,
entityFilter entityFilter
); );
return { entity: foundEntities[0] || "", graph: "line" }; return { type: "sensor", entity: foundEntities[0] || "", graph: "line" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;

View File

@ -37,8 +37,8 @@ class HuiShoppingListCard extends LitElement implements LovelaceCard {
return document.createElement("hui-shopping-list-card-editor"); return document.createElement("hui-shopping-list-card-editor");
} }
public static getStubConfig(): object { public static getStubConfig(): ShoppingListCardConfig {
return {}; return { type: "shopping-list" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;

View File

@ -34,7 +34,6 @@ import {
} from "../../../data/climate"; } from "../../../data/climate";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
import { UNAVAILABLE } from "../../../data/entity"; import { UNAVAILABLE } from "../../../data/entity";
@ -59,22 +58,20 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): ThermostatCardConfig {
): object {
const includeDomains = ["climate"]; const includeDomains = ["climate"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entity: foundEntities[0] || "" }; return { type: "thermostat", entity: foundEntities[0] || "" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;

View File

@ -24,7 +24,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { toggleAttribute } from "../../../common/dom/toggle_attribute"; import { toggleAttribute } from "../../../common/dom/toggle_attribute";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites"; import { findEntities } from "../common/find-entites";
const cardinalDirections = [ const cardinalDirections = [
@ -76,22 +75,20 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig, entities: string[],
entities?: string[], entitiesFallback: string[]
entitiesFill?: string[] ): WeatherForecastCardConfig {
): object {
const includeDomains = ["weather"]; const includeDomains = ["weather"];
const maxEntities = 1; const maxEntities = 1;
const foundEntities = findEntities( const foundEntities = findEntities(
hass, hass,
lovelaceConfig,
maxEntities, maxEntities,
entities, entities,
entitiesFill, entitiesFallback,
includeDomains includeDomains
); );
return { entity: foundEntities[0] || "" }; return { type: "weather-forecast", entity: foundEntities[0] || "" };
} }
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;

View File

@ -33,13 +33,15 @@ export interface EntitiesCardEntityConfig extends EntityConfig {
hold_action?: ActionConfig; hold_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
state_color?: boolean; state_color?: boolean;
show_name?: boolean;
show_icon?: boolean;
} }
export interface EntitiesCardConfig extends LovelaceCardConfig { export interface EntitiesCardConfig extends LovelaceCardConfig {
type: "entities"; type: "entities";
show_header_toggle?: boolean; show_header_toggle?: boolean;
title?: string; title?: string;
entities: EntitiesCardEntityConfig[]; entities: Array<EntitiesCardEntityConfig | string>;
theme?: string; theme?: string;
icon?: string; icon?: string;
header?: LovelaceHeaderFooterConfig; header?: LovelaceHeaderFooterConfig;
@ -135,8 +137,8 @@ export interface LightCardConfig extends LovelaceCardConfig {
export interface MapCardConfig extends LovelaceCardConfig { export interface MapCardConfig extends LovelaceCardConfig {
type: "map"; type: "map";
title: string; title?: string;
aspect_ratio: string; aspect_ratio?: string;
default_zoom?: number; default_zoom?: number;
entities?: Array<EntityConfig | string>; entities?: Array<EntityConfig | string>;
geo_location_sources?: string[]; geo_location_sources?: string[];

View File

@ -50,18 +50,18 @@ const addEntities = (entities: Set<string>, obj) => {
if (obj.card) { if (obj.card) {
addEntities(entities, obj.card); addEntities(entities, obj.card);
} }
if (obj.cards) { if (obj.cards && Array.isArray(obj.cards)) {
obj.cards.forEach((card) => addEntities(entities, card)); obj.cards.forEach((card) => addEntities(entities, card));
} }
if (obj.elements) { if (obj.elements && Array.isArray(obj.elements)) {
obj.elements.forEach((card) => addEntities(entities, card)); obj.elements.forEach((card) => addEntities(entities, card));
} }
if (obj.badges) { if (obj.badges && Array.isArray(obj.badges)) {
obj.badges.forEach((badge) => addEntityId(entities, badge)); obj.badges.forEach((badge) => addEntityId(entities, badge));
} }
}; };
export const computeUsedEntities = (config) => { export const computeUsedEntities = (config: LovelaceConfig): Set<string> => {
const entities = new Set<string>(); const entities = new Set<string>();
config.views.forEach((view) => addEntities(entities, view)); config.views.forEach((view) => addEntities(entities, view));
return entities; return entities;
@ -70,13 +70,26 @@ export const computeUsedEntities = (config) => {
export const computeUnusedEntities = ( export const computeUnusedEntities = (
hass: HomeAssistant, hass: HomeAssistant,
config: LovelaceConfig config: LovelaceConfig
): string[] => { ): Set<string> => {
const usedEntities = computeUsedEntities(config); const usedEntities = computeUsedEntities(config);
return Object.keys(hass.states) const unusedEntities = calcUnusedEntities(hass, usedEntities);
.filter( return unusedEntities;
(entity) => };
!usedEntities.has(entity) &&
!EXCLUDED_DOMAINS.includes(entity.split(".", 1)[0]) export const calcUnusedEntities = (
) hass: HomeAssistant,
.sort(); usedEntities: Set<string>
): Set<string> => {
const unusedEntities: Set<string> = new Set();
for (const entity of Object.keys(hass.states)) {
if (
!usedEntities.has(entity) &&
!EXCLUDED_DOMAINS.includes(entity.split(".", 1)[0])
) {
unusedEntities.add(entity);
}
}
return unusedEntities;
}; };

View File

@ -1,59 +1,72 @@
import {
computeUnusedEntities,
computeUsedEntities,
} from "./compute-unused-entities";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { LovelaceConfig } from "../../../data/lovelace";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
const arrayFilter = (
array: any[],
conditions: Array<(value: any) => boolean>,
maxSize: number
) => {
if (!maxSize || maxSize > array.length) {
maxSize = array.length;
}
const filteredArray: any[] = [];
for (let i = 0; i < array.length && filteredArray.length < maxSize; i++) {
let meetsConditions = true;
for (const condition of conditions) {
if (!condition(array[i])) {
meetsConditions = false;
break;
}
}
if (meetsConditions) {
filteredArray.push(array[i]);
}
}
return filteredArray;
};
export const findEntities = ( export const findEntities = (
hass: HomeAssistant, hass: HomeAssistant,
lovelaceConfig: LovelaceConfig,
maxEntities: number, maxEntities: number,
entities?: string[], entities: string[],
entitiesFill?: string[], entitiesFallback: string[],
includeDomains?: string[], includeDomains?: string[],
entityFilter?: (stateObj: HassEntity) => boolean entityFilter?: (stateObj: HassEntity) => boolean
) => { ) => {
let entityIds: string[]; let entityIds: string[];
entityIds = !entities?.length const conditions: Array<(value: string) => boolean> = [];
? computeUnusedEntities(hass, lovelaceConfig)
: entities;
if (includeDomains && includeDomains.length) { if (includeDomains?.length) {
entityIds = entityIds.filter((eid) => conditions.push((eid) => includeDomains!.includes(computeDomain(eid)));
includeDomains!.includes(computeDomain(eid))
);
} }
if (entityFilter) { if (entityFilter) {
entityIds = entityIds.filter( conditions.push(
(eid) => hass.states[eid] && entityFilter(hass.states[eid]) (eid) => hass.states[eid] && entityFilter(hass.states[eid])
); );
} }
if (entityIds.length < (maxEntities || 1)) { entityIds = arrayFilter(entities, conditions, maxEntities);
let fillEntityIds =
entitiesFill && entitiesFill.length
? entitiesFill
: [...computeUsedEntities(lovelaceConfig)];
if (includeDomains && includeDomains.length) { if (entityIds.length < maxEntities && entitiesFallback.length) {
fillEntityIds = fillEntityIds.filter((eid) => const fallbackEntityIds = findEntities(
includeDomains!.includes(computeDomain(eid)) hass,
); maxEntities - entityIds.length,
} entitiesFallback,
[],
includeDomains,
entityFilter
);
if (entityFilter) { entityIds.push(...fallbackEntityIds);
fillEntityIds = fillEntityIds.filter(
(eid) => hass.states[eid] && entityFilter(hass.states[eid])
);
}
entityIds = [...entityIds, ...fillEntityIds];
} }
return entityIds.slice(0, maxEntities); return entityIds;
}; };

View File

@ -0,0 +1,109 @@
import { strokeWidth } from "../../../../data/graph";
const average = (items: any[]): number => {
return (
items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) /
items.length
);
};
const lastValue = (items: any[]): number => {
return parseFloat(items[items.length - 1].state) || 0;
};
const calcPoints = (
history: any,
hours: number,
width: number,
detail: number,
min: number,
max: number
): number[][] => {
const coords = [] as number[][];
const height = 80;
let yRatio = (max - min) / height;
yRatio = yRatio !== 0 ? yRatio : height;
let xRatio = width / (hours - (detail === 1 ? 1 : 0));
xRatio = isFinite(xRatio) ? xRatio : width;
const first = history.filter(Boolean)[0];
let last = [average(first), lastValue(first)];
const getCoords = (item: any[], i: number, offset = 0, depth = 1) => {
if (depth > 1 && item) {
return item.forEach((subItem, index) =>
getCoords(subItem, i, index, depth - 1)
);
}
const x = xRatio * (i + offset / 6);
if (item) {
last = [average(item), lastValue(item)];
}
const y =
height + strokeWidth / 2 - ((item ? last[0] : last[1]) - min) / yRatio;
return coords.push([x, y]);
};
for (let i = 0; i < history.length; i += 1) {
getCoords(history[i], i, 0, detail);
}
if (coords.length === 1) {
coords[1] = [width, coords[0][1]];
}
coords.push([width, coords[coords.length - 1][1]]);
return coords;
};
export const coordinates = (
history: any,
hours: number,
width: number,
detail: number
): number[][] | undefined => {
history.forEach((item) => (item.state = Number(item.state)));
history = history.filter((item) => !Number.isNaN(item.state));
const min = Math.min.apply(
Math,
history.map((item) => item.state)
);
const max = Math.max.apply(
Math,
history.map((item) => item.state)
);
const now = new Date().getTime();
const reduce = (res, item, point) => {
const age = now - new Date(item.last_changed).getTime();
let key = Math.abs(age / (1000 * 3600) - hours);
if (point) {
key = (key - Math.floor(key)) * 60;
key = Number((Math.round(key / 10) * 10).toString()[0]);
} else {
key = Math.floor(key);
}
if (!res[key]) {
res[key] = [];
}
res[key].push(item);
return res;
};
history = history.reduce((res, item) => reduce(res, item, false), []);
if (detail > 1) {
history = history.map((entry) =>
entry.reduce((res, item) => reduce(res, item, true), [])
);
}
if (!history.length) {
return undefined;
}
return calcPoints(history, hours, width, detail, min, max);
};

View File

@ -0,0 +1,24 @@
import { fetchRecent } from "../../../../data/history";
import { coordinates } from "../graph/coordinates";
import { HomeAssistant } from "../../../../types";
export const getHistoryCoordinates = async (
hass: HomeAssistant,
entity: string,
hours: number,
detail: number
) => {
const endTime = new Date();
const startTime = new Date();
startTime.setHours(endTime.getHours() - hours);
const stateHistory = await fetchRecent(hass, entity, startTime, endTime);
if (stateHistory.length < 1 || stateHistory[0].length < 1) {
return;
}
const coords = coordinates(stateHistory[0], hours, 500, detail);
return coords;
};

View File

@ -0,0 +1,36 @@
const midPoint = (
_Ax: number,
_Ay: number,
_Bx: number,
_By: number
): number[] => {
const _Zx = (_Ax - _Bx) / 2 + _Bx;
const _Zy = (_Ay - _By) / 2 + _By;
return [_Zx, _Zy];
};
export const getPath = (coords: number[][]): string => {
if (!coords.length) {
return "";
}
let next: number[];
let Z: number[];
const X = 0;
const Y = 1;
let path = "";
let last = coords.filter(Boolean)[0];
path += `M ${last[X]},${last[Y]}`;
for (const coord of coords) {
next = coord;
Z = midPoint(last[X], last[Y], next[X], next[Y]);
path += ` ${Z[X]},${Z[Y]}`;
path += ` Q${next[X]},${next[Y]}`;
last = next;
}
path += ` ${next![X]},${next![Y]}`;
return path;
};

View File

@ -22,6 +22,7 @@ import { hasAction } from "../common/has-action";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { EntitiesCardEntityConfig } from "../cards/types"; import { EntitiesCardEntityConfig } from "../cards/types";
import { computeStateName } from "../../../common/entity/compute_state_name";
@customElement("hui-buttons-base") @customElement("hui-buttons-base")
export class HuiButtonsBase extends LitElement { export class HuiButtonsBase extends LitElement {
@ -31,9 +32,12 @@ export class HuiButtonsBase extends LitElement {
set hass(hass: HomeAssistant) { set hass(hass: HomeAssistant) {
this._hass = hass; this._hass = hass;
const entitiesShowingIcons = this.configEntities?.filter(
(entity) => entity.show_icon !== false
);
this._badges.forEach((badge, index: number) => { this._badges.forEach((badge, index: number) => {
badge.hass = hass; badge.hass = hass;
badge.stateObj = hass.states[this.configEntities![index].entity]; badge.stateObj = hass.states[entitiesShowingIcons![index].entity];
}); });
} }
@ -46,22 +50,33 @@ export class HuiButtonsBase extends LitElement {
} }
return html` return html`
<div> <div
<state-badge @action=${this._handleAction}
title=${computeTooltip(this._hass!, entityConf)} .actionHandler=${actionHandler({
@action=${this._handleAction} hasHold: hasAction(entityConf.hold_action),
.actionHandler=${actionHandler({ hasDoubleClick: hasAction(entityConf.double_tap_action),
hasHold: hasAction(entityConf.hold_action), })}
hasDoubleClick: hasAction(entityConf.double_tap_action), .config=${entityConf}
})} tabindex="0"
.config=${entityConf} >
.hass=${this._hass} ${entityConf.show_icon !== false
.stateObj=${stateObj} ? html`
.overrideIcon=${entityConf.icon} <state-badge
.overrideImage=${entityConf.image} title=${computeTooltip(this._hass!, entityConf)}
stateColor .hass=${this._hass}
tabindex="0" .stateObj=${stateObj}
></state-badge> .overrideIcon=${entityConf.icon}
.overrideImage=${entityConf.image}
stateColor
></state-badge>
`
: ""}
<span>
${entityConf.show_name ||
(entityConf.name && entityConf.show_name !== false)
? entityConf.name || computeStateName(stateObj)
: ""}
</span>
<mwc-ripple unbounded></mwc-ripple> <mwc-ripple unbounded></mwc-ripple>
</div> </div>
`; `;
@ -88,8 +103,10 @@ export class HuiButtonsBase extends LitElement {
.missing { .missing {
color: #fce588; color: #fce588;
} }
state-badge { div {
cursor: pointer; cursor: pointer;
align-items: center;
display: inline-flex;
} }
`; `;
} }

View File

@ -78,6 +78,11 @@ export class HuiCardOptions extends LitElement {
"ui.panel.lovelace.editor.edit_card.move" "ui.panel.lovelace.editor.edit_card.move"
)}</paper-item )}</paper-item
> >
<paper-item @tap=${this._duplicateCard}
>${this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.duplicate"
)}</paper-item
>
<paper-item .class="delete-item" @tap="${this._deleteCard}"> <paper-item .class="delete-item" @tap="${this._deleteCard}">
${this.hass!.localize( ${this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.delete" "ui.panel.lovelace.editor.edit_card.delete"
@ -152,6 +157,17 @@ export class HuiCardOptions extends LitElement {
`; `;
} }
private _duplicateCard(): void {
const path = this.path!;
const cardConfig = this.lovelace!.config.views[path[0]].cards![path[1]];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
cardConfig,
saveConfig: this.lovelace!.saveConfig,
path: [path[0]],
});
}
private _editCard(): void { private _editCard(): void {
showEditCardDialog(this, { showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config, lovelaceConfig: this.lovelace!.config,

View File

@ -0,0 +1,78 @@
import {
html,
LitElement,
TemplateResult,
customElement,
property,
css,
CSSResult,
svg,
PropertyValues,
} from "lit-element";
import { strokeWidth } from "../../../data/graph";
import { getPath } from "../common/graph/get-path";
@customElement("hui-graph-base")
export class HuiGraphBase extends LitElement {
@property() public coordinates?: any;
@property() private _path?: string;
protected render(): TemplateResult {
return html`
${this._path
? svg`<svg width="100%" height="100%" viewBox="0 0 500 100">
<g>
<mask id="fill">
<path
class='fill'
fill='white'
d="${this._path} L 500, 100 L 0, 100 z"
/>
</mask>
<rect height="100%" width="100%" id="fill-rect" fill="var(--accent-color)" mask="url(#fill)"></rect>
<mask id="line">
<path
fill="none"
stroke="var(--accent-color)"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
d=${this._path}
></path>
</mask>
<rect height="100%" width="100%" id="rect" fill="var(--accent-color)" mask="url(#line)"></rect>
</g>
</svg>`
: svg`<svg width="100%" height="100%" viewBox="0 0 500 100"></svg>`}
`;
}
protected updated(changedProps: PropertyValues) {
if (!this.coordinates) {
return;
}
if (changedProps.has("coordinates")) {
this._path = getPath(this.coordinates);
}
}
static get styles(): CSSResult {
return css`
:host {
display: flex;
width: 100%;
}
.fill {
opacity: 0.1;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-graph-base": HuiGraphBase;
}
}

View File

@ -10,18 +10,7 @@ import {
import "@material/mwc-button"; import "@material/mwc-button";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { fireEvent, HASSDomEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
declare global {
// for fire event
interface HASSDomEvents {
"theme-changed": undefined;
}
// for add event listener
interface HTMLElementEventMap {
"theme-changed": HASSDomEvent<undefined>;
}
}
@customElement("hui-theme-select-editor") @customElement("hui-theme-select-editor")
export class HuiThemeSelectEditor extends LitElement { export class HuiThemeSelectEditor extends LitElement {
@ -30,32 +19,34 @@ export class HuiThemeSelectEditor extends LitElement {
@property() public hass?: HomeAssistant; @property() public hass?: HomeAssistant;
protected render(): TemplateResult { protected render(): TemplateResult {
const themes = ["Backend-selected", "default"].concat(
Object.keys(this.hass!.themes.themes).sort()
);
return html` return html`
<paper-dropdown-menu <paper-dropdown-menu
.label=${this.label || .label=${this.label ||
this.hass!.localize("ui.panel.lovelace.editor.card.generic.theme") + `${this.hass!.localize(
" (" + "ui.panel.lovelace.editor.card.generic.theme"
this.hass!.localize( )} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional" "ui.panel.lovelace.editor.card.config.optional"
) + )})`}
")"}
dynamic-align dynamic-align
@value-changed="${this._changed}"
> >
<paper-listbox <paper-listbox
slot="dropdown-content" slot="dropdown-content"
.selected="${this.value}" .selected=${this.value}
attr-for-selected="theme" attr-for-selected="theme"
@iron-select=${this._changed}
> >
${themes.map((theme) => { <paper-item theme="remove"
return html` >${this.hass!.localize(
<paper-item theme="${theme}">${theme}</paper-item> "ui.panel.lovelace.editor.card.generic.no_theme"
`; )}</paper-item
})} >
${Object.keys(this.hass!.themes.themes)
.sort()
.map((theme) => {
return html`
<paper-item theme=${theme}>${theme}</paper-item>
`;
})}
</paper-listbox> </paper-listbox>
</paper-dropdown-menu> </paper-dropdown-menu>
`; `;
@ -70,11 +61,11 @@ export class HuiThemeSelectEditor extends LitElement {
} }
private _changed(ev): void { private _changed(ev): void {
if (!this.hass || ev.target.value === "") { if (!this.hass || ev.target.selected === "") {
return; return;
} }
this.value = ev.target.value; this.value = ev.target.selected === "remove" ? "" : ev.target.selected;
fireEvent(this, "theme-changed"); fireEvent(this, "value-changed", { value: this.value });
} }
} }

View File

@ -14,9 +14,8 @@ export class HuiUnavailable extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div class="disabled-overlay"> <div class="disabled-overlay"></div>
<div>${this.text}</div> <div class="disabled-overlay-text">${this.text}</div>
</div>
`; `;
} }
@ -31,11 +30,11 @@ export class HuiUnavailable extends LitElement {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--state-icon-unavailable-color); background-color: var(--state-icon-unavailable-color);
opacity: 0.5; opacity: 0.6;
z-index: 50; z-index: 50;
} }
.disabled-overlay div { .disabled-overlay-text {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -44,6 +43,8 @@ export class HuiUnavailable extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%); -ms-transform: translate(-50%, -50%);
z-index: 50;
opacity: 0.7;
} }
`; `;
} }

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