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
variables:
- name: versionWheels
value: '1.3-3.7-alpine3.10'
value: '1.10.1-3.7-alpine3.11'
- name: versionNode
value: '12.1'
- 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_title: "Epic sax guy 10 hours",
app_name: "YouTube",
entity_picture: "/images/frenck.jpg",
supported_features: 33,
}),
getEntity("media_player", "living_room", "playing", {
@ -42,6 +43,7 @@ export const createMediaPlayerEntities = () => [
media_title: "Chapter 1",
media_series_title: "House of Cards",
app_name: "Netflix",
entity_picture: "/images/netflix.jpg",
supported_features: 1,
}),
getEntity("media_player", "sonos_idle", "idle", {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,10 @@
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 shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
@ -15,67 +21,82 @@ const hexToRgb = (hex: string): string | null => {
: null;
};
let PROCESSED_THEMES: { [key: string]: ProcessedTheme } = {};
/**
* Apply a theme to an element by setting the CSS variables on it.
*
* element: Element to apply theme on.
* themes: HASS Theme information
* localTheme: selected theme.
* updateMeta: boolean if we should update the theme-color meta element.
* selectedTheme: selected theme.
*/
export const applyThemesOnElement = (
element,
themes,
localTheme,
updateMeta = false
themes: HomeAssistant["themes"],
selectedTheme?: string
) => {
if (!element._themes) {
element._themes = {};
}
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);
}
const newTheme = selectedTheme
? PROCESSED_THEMES[selectedTheme] || processTheme(selectedTheme, themes)
: undefined;
if (!updateMeta) {
if (!element._themes && !newTheme) {
// No styles to reset, and no styles to set
return;
}
const meta = document.querySelector("meta[name=theme-color]");
if (meta) {
if (!meta.hasAttribute("default-content")) {
meta.setAttribute("default-content", meta.getAttribute("content")!);
}
const themeColor =
styles["--primary-color"] || meta.getAttribute("default-content");
meta.setAttribute("content", themeColor);
// Add previous set keys to reset them, and new theme
const styles = { ...element._themes, ...newTheme?.styles };
element._themes = newTheme?.keys;
// Set and/or reset styles
if (element.updateStyles) {
element.updateStyles(styles);
} 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 => {
const open = state.state !== "closed";
switch (state.attributes.device_class) {
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":
return open ? "hass:door-open" : "hass:door-closed";
case "damper":
return open ? "hass:circle" : "hass:circle-slice-8";
case "shutter":
return open ? "hass:window-shutter-open" : "hass:window-shutter";
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":
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:
return domainIcon("cover", state.state);
}

View File

@ -77,7 +77,16 @@ export const domainIcon = (domain: string, state?: string): string => {
: "hass:checkbox-marked-circle";
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":
return state && state === "unlocked" ? "hass:lock-open" : "hass:lock";

View File

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

View File

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

View File

@ -19,7 +19,7 @@ class HaCoverControls extends PolymerElement {
<div class="state">
<paper-icon-button
aria-label="Open cover"
icon="hass:arrow-up"
icon="[[computeOpenIcon(stateObj)]]"
on-click="onOpenTap"
invisible$="[[!entityObj.supportsOpen]]"
disabled="[[computeOpenDisabled(stateObj, entityObj)]]"
@ -32,7 +32,7 @@ class HaCoverControls extends PolymerElement {
></paper-icon-button>
<paper-icon-button
aria-label="Close cover"
icon="hass:arrow-down"
icon="[[computeCloseIcon(stateObj)]]"
on-click="onCloseTap"
invisible$="[[!entityObj.supportsClose]]"
disabled="[[computeClosedDisabled(stateObj, entityObj)]]"
@ -60,6 +60,26 @@ class HaCoverControls extends PolymerElement {
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) {
var assumedState = stateObj.attributes.assumed_state === true;
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 { computeRTL } from "../common/util/compute_rtl";
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"];
@ -87,10 +87,8 @@ const computePanels = (hass: HomeAssistant): [PanelInfo[], PanelInfo[]] => {
const beforeSpacer: PanelInfo[] = [];
const afterSpacer: PanelInfo[] = [];
const defaultPage = getDefaultPanelUrlPath();
Object.values(panels).forEach((panel) => {
if (!panel.title || panel.url_path === defaultPage) {
if (!panel.title || panel.url_path === hass.defaultPanel) {
return;
}
(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`
<div class="menu">
@ -297,7 +295,8 @@ class HaSidebar extends LitElement {
hass.panelUrl !== oldHass.panelUrl ||
hass.user !== oldHass.user ||
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;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
background: none;
}
a {

View File

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

View File

@ -25,3 +25,16 @@ export const fetchAuthProviders = () =>
fetch("/auth/providers", {
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;
};
export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
devices.filter((device) => device.area_id === areaId);
export const updateDeviceRegistryEntry = (
hass: HomeAssistant,
deviceId: string,

View File

@ -27,6 +27,16 @@ export interface EntityRegistryEntryUpdateParams {
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 = (
hass: HomeAssistant,
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";
machine: any;
homeassistant: string;
last_version: string;
version_latest: string;
boot: "auto" | "manual";
build: boolean;
options: object;

View File

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

View File

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

View File

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

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_READ_ONLY = "system-read-only";
export const GROUPS = [SYSTEM_GROUP_ID_USER, SYSTEM_GROUP_ID_ADMIN];
export interface User {
id: string;
name: string;
@ -15,7 +17,7 @@ export interface User {
credentials: Credential[];
}
interface UpdateUserParams {
export interface UpdateUserParams {
name?: User["name"];
group_ids?: User["group_ids"];
}
@ -25,10 +27,16 @@ export const fetchUsers = async (hass: HomeAssistant) =>
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 }>({
type: "config/auth/create",
name,
group_ids,
});
export const updateUser = async (

View File

@ -130,7 +130,11 @@ class DataEntryFlowDialog extends LitElement {
>
${this._loading || (this._step === null && this._handlers === undefined)
? 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
? // When we are going to next step, we render 1 round of empty

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ export const demoConfig: HassConfig = {
temperature: "°C",
volume: "L",
},
components: ["notify.html5", "history"],
components: ["notify.html5", "history", "shopping_list"],
time_zone: "America/Los_Angeles",
config_dir: "/config",
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 { demoServices } from "./demo_services";
@ -8,6 +11,7 @@ import { HomeAssistant } from "../types";
import { HassEntities } from "home-assistant-js-websocket";
import { getLocalLanguage } from "../util/hass-translation";
import { translationMetadata } from "../resources/translations-metadata";
import { DEFAULT_PANEL } from "../data/panel";
const ensureArray = <T>(val: T | T[]): T[] =>
Array.isArray(val) ? val : [val];
@ -169,6 +173,7 @@ export const provideHass = (
name: "Demo User",
},
panelUrl: "lovelace",
defaultPanel: DEFAULT_PANEL,
language: localLanguage,
selectedLanguage: localLanguage,
@ -224,6 +229,7 @@ export const provideHass = (
(eventListeners[event] || []).forEach((fn) => fn(event));
},
mockTheme(theme) {
invalidateThemeCache();
hass().updateHass({
selectedTheme: theme ? "mock" : "default",
themes: {
@ -237,8 +243,7 @@ export const provideHass = (
applyThemesOnElement(
document.documentElement,
themes,
selectedTheme,
true
selectedTheme as string
);
},

View File

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

View File

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

View File

@ -109,9 +109,9 @@ class DialogAreaDetail extends LitElement {
name: this._name.trim(),
};
if (this._params!.entry) {
await this._params!.updateEntry(values);
await this._params!.updateEntry!(values);
} else {
await this._params!.createEntry(values);
await this._params!.createEntry!(values);
}
this._params = undefined;
} catch (err) {
@ -124,7 +124,7 @@ class DialogAreaDetail extends LitElement {
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry()) {
if (await this._params!.removeEntry!()) {
this._params = undefined;
}
} 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 {
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,
AreaRegistryEntry,
} 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 {
showAreaRegistryDetailDialog,
loadAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { classMap } from "lit-html/directives/class-map";
import { computeRTL } from "../../../common/util/compute_rtl";
HassRouterPage,
RouterOptions,
} from "../../../layouts/hass-router-page";
import { property, customElement, PropertyValues } from "lit-element";
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 { configSections } from "../ha-panel-config";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-config-areas")
export class HaConfigAreas extends LitElement {
class HaConfigAreas extends HassRouterPage {
@property() public hass!: HomeAssistant;
@property() public isWide?: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _areas?: AreaRegistryEntry[];
private _unsubAreas?: UnsubscribeFunc;
@property() public isWide!: boolean;
@property() public showAdvanced!: boolean;
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() {
super.disconnectedCallback();
if (this._unsubAreas) {
this._unsubAreas();
if (this._unsubs) {
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) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
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;
}
},
this.addEventListener("hass-reload-entries", () => {
this._loadData();
});
}
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);
}
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;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!this._unsubs && changedProps.has("hass")) {
this._loadData();
}
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab.rtl {
right: auto;
left: 16px;
}
protected updatePageEl(pageEl) {
pageEl.hass = this.hass;
ha-fab[is-wide].rtl {
bottom: 24px;
right: auto;
left: 24px;
}
`;
if (this._currentPage === "area") {
pageEl.areaId = this.routeTail.path.substr(1);
}
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 {
entry?: AreaRegistryEntry;
createEntry: (values: AreaRegistryEntryMutableParams) => Promise<unknown>;
updateEntry: (
createEntry?: (values: AreaRegistryEntryMutableParams) => Promise<unknown>;
updateEntry?: (
updates: Partial<AreaRegistryEntryMutableParams>
) => Promise<unknown>;
removeEntry: () => Promise<boolean>;
removeEntry?: () => Promise<boolean>;
}
export const loadAreaRegistryDetailDialog = () =>

View File

@ -7,18 +7,13 @@ import {
property,
customElement,
} from "lit-element";
import { ifDefined } from "lit-html/directives/if-defined";
import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-item/paper-item-body";
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/entity/ha-entity-toggle";
import "../ha-config-section";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeRTL } from "../../../common/util/compute_rtl";
import { haStyle } from "../../../resources/styles";
@ -27,12 +22,16 @@ import {
AutomationEntity,
showAutomationEditor,
AutomationConfig,
triggerAutomation,
} from "../../../data/automation";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { showThingtalkDialog } from "./show-dialog-thingtalk";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
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")
class HaAutomationPicker extends LitElement {
@ -42,139 +41,152 @@ class HaAutomationPicker extends LitElement {
@property() public route!: Route;
@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 {
return html`
<hass-tabs-subpage
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.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}>
<div slot="header">
${this.hass.localize("ui.panel.config.automation.picker.header")}
</div>
<div slot="introduction">
${this.hass.localize(
"ui.panel.config.automation.picker.introduction"
)}
<p>
<a
href="https://home-assistant.io/docs/automation/editor/"
target="_blank"
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>
</hass-tabs-subpage-data-table>
<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>
`;
}
private _showInfo(ev) {
ev.stopPropagation();
const entityId = ev.currentTarget.automation.entity_id;
fireEvent(this, "hass-more-info", { entityId });
}
private _execute(ev) {
const entityId = ev.currentTarget.automation.entity_id;
triggerAutomation(this.hass, entityId);
}
private _createNew() {
if (!isComponentLoaded(this.hass, "cloud")) {
showAutomationEditor(this);
@ -190,33 +202,6 @@ class HaAutomationPicker extends LitElement {
return [
haStyle,
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 {
position: fixed;
bottom: 16px;
@ -242,10 +227,6 @@ class HaAutomationPicker extends LitElement {
right: auto;
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 {
return css`
:host {
display: block;
}
ha-icon {
width: 40px;
}
@ -182,6 +185,9 @@ export class HaDeviceEntitiesCard extends LitElement {
#entities > * {
margin: 8px 16px 8px 8px;
}
#entities > paper-icon-item {
margin: 0;
}
paper-icon-item {
min-height: 40px;
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 "../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 { HomeAssistant, Route } from "../../../types";
import { ConfigEntry } from "../../../data/config_entries";
import {
EntityRegistryEntry,
updateEntityRegistryEntry,
findBatteryEntity,
} from "../../../data/entity_registry";
import {
DeviceRegistryEntry,
@ -40,9 +42,9 @@ import { createValidEntityId } from "../../../common/entity/valid_entity_id";
import { configSections } from "../ha-panel-config";
import { RelatedResult, findRelated } from "../../../data/search";
import { SceneEntities, showSceneEditor } from "../../../data/scene";
import { navigate } from "../../../common/navigate";
import { showDeviceAutomationDialog } from "./device-detail/show-dialog-device-automation";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ifDefined } from "lit-html/directives/if-defined";
export interface EntityRegistryStateEntry extends EntityRegistryEntry {
stateName?: string;
@ -70,6 +72,13 @@ export class HaConfigDevicePage extends LitElement {
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(
(
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) {
super.firstUpdated(changedProps);
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 batteryEntity = this._batteryEntity(entities);
const batteryState = batteryEntity
? this.hass.states[batteryEntity.entity_id]
: undefined;
const areaName = this._computeAreaName(this.areas, device);
return html`
<hass-tabs-subpage
@ -139,22 +158,70 @@ export class HaConfigDevicePage extends LitElement {
></paper-icon-button>
<div class="container">
<div class="left">
<div class="device-info">
${
this.narrow
? ""
: html`
<div class="header fullwidth">
${
this.narrow
? ""
: html`
<div>
<h1>${computeDeviceName(device, this.hass)}</h1>
`
}
<ha-device-card
${areaName
? this.hass.localize(
"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}
.areas=${this.areas}
.devices=${this.devices}
.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
@ -168,32 +235,46 @@ export class HaConfigDevicePage extends LitElement {
: html``
}
</div>
<div class="right">
<div class="column">
${
isComponentLoaded(this.hass, "automation")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations"
)}
>${this._related?.automation?.length
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.devices.automation.automations"
)}
<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) => {
const state = this.hass.states[automation];
return state
? html`
<div>
<paper-item
.automation=${state}
@click=${this._openAutomation}
.disabled=${!state.attributes.id}
<a
href=${ifDefined(
state.attributes.id
? `/config/automation/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item-body>
${state.attributes.friendly_name ||
automation}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
<paper-item
.automation=${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
@ -214,13 +295,6 @@ export class HaConfigDevicePage extends LitElement {
)}</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>
`
: ""
@ -230,58 +304,72 @@ export class HaConfigDevicePage extends LitElement {
${
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
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.devices.scene.scenes"
)}
${
entities.length
? html`
<div>
<paper-item
.scene=${state}
@click=${this._openScene}
.disabled=${!state.attributes.id}
>
<paper-item-body>
${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>
<paper-icon-button
@click=${this._createScene}
title=${this.hass.localize(
"ui.panel.config.devices.scene.create"
)}
icon="hass:plus-circle"
></paper-icon-button>
`
: "";
})
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}</paper-item
>
`}
${entities.length
? html`
<div class="card-actions">
<mwc-button @click=${this._createScene}>
${this.hass.localize(
"ui.panel.config.devices.scene.create"
)}
</mwc-button>
</div>
`
: ""}
: ""
}
</div>
${
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
.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>
`
: ""
@ -289,25 +377,38 @@ export class HaConfigDevicePage extends LitElement {
${
isComponentLoaded(this.hass, "script")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts"
)}
>${this._related?.script?.length
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.devices.script.scripts"
)}
<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) => {
const state = this.hass.states[script];
return state
? html`
<paper-item
.script=${script}
@click=${this._openScript}
<a
href=${ifDefined(
state.attributes.id
? `/config/script/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item-body>
${state.attributes.friendly_name ||
script}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
<paper-item .script=${script}>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
`
: "";
})
@ -318,19 +419,11 @@ export class HaConfigDevicePage extends LitElement {
)}</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>
`
: ""
}
</div>
</div>
</div>
</ha-config-section>
</hass-tabs-subpage> `;
@ -344,6 +437,21 @@ export class HaConfigDevicePage extends LitElement {
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() {
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() {
showDeviceAutomationDialog(this, { deviceId: this.deviceId, script: true });
}
@ -459,6 +548,18 @@ export class HaConfigDevicePage extends LitElement {
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 {
padding: 16px;
}
@ -467,7 +568,7 @@ export class HaConfigDevicePage extends LitElement {
}
h1 {
margin-top: 0;
margin: 0;
font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var(
--paper-font-headline_-_-webkit-font-smoothing
@ -479,46 +580,60 @@ export class HaConfigDevicePage extends LitElement {
opacity: var(--dark-primary-opacity);
}
.left,
.header {
display: flex;
justify-content: space-between;
}
.column,
.fullwidth {
padding: 8px;
box-sizing: border-box;
}
.left {
width: 33.33%;
padding-bottom: 0;
.column {
width: 33%;
flex-grow: 1;
}
.right {
width: 66.66%;
display: flex;
flex-wrap: wrap;
}
.fullwidth {
width: 100%;
flex-grow: 1;
}
.column {
width: 50%;
.header-right {
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) {
margin-top: 16px;
}
:host([narrow]) .left,
:host([narrow]) .right,
:host([narrow]) .column {
width: 100%;
}
:host([narrow]) .container > *:first-child {
padding-top: 0;
}
:host([narrow]) .container {
margin-top: 0;
}
@ -530,6 +645,11 @@ export class HaConfigDevicePage extends LitElement {
paper-item.no-link {
cursor: default;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -184,8 +184,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
private _getItems = memoize((dashboards: LovelaceDashboard[]) => {
const defaultMode = (this.hass.panels?.lovelace
?.config as LovelacePanelConfig).mode;
const isDefault =
!localStorage.defaultPage || localStorage.defaultPage === "lovelace";
const defaultUrlPath = this.hass.defaultPanel;
const isDefault = defaultUrlPath === "lovelace";
return [
{
icon: "hass:view-dashboard",
@ -201,7 +201,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
return {
filename: "",
...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-item/paper-item-body";
import "@polymer/paper-tooltip/paper-tooltip";
import "../../../layouts/hass-tabs-subpage";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../ha-config-section";
import {
css,
CSSResultArray,
customElement,
html,
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 { 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 { HomeAssistant, Route } from "../../../types";
import { SceneEntity, activateScene } from "../../../data/scene";
import { showToast } from "../../../util/toast";
import { ifDefined } from "lit-html/directives/if-defined";
import { forwardHaptic } from "../../../data/haptics";
import { configSections } from "../ha-panel-config";
@customElement("ha-scene-dashboard")
@ -35,108 +33,131 @@ class HaSceneDashboard extends LitElement {
@property() public route!: Route;
@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 {
return html`
<hass-tabs-subpage
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.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}>
<div slot="header">
${this.hass.localize("ui.panel.config.scene.picker.header")}
</div>
<div slot="introduction">
${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>
</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>
<paper-icon-button
slot="toolbar-icon"
icon="hass:help-circle"
@click=${this._showHelp}
></paper-icon-button>
</hass-tabs-subpage-data-table>
<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>
`;
}
private _showInfo(ev) {
ev.stopPropagation();
const entityId = ev.currentTarget.scene.entity_id;
fireEvent(this, "hass-more-info", { entityId });
}
private async _activateScene(ev) {
ev.stopPropagation();
const scene = ev.target.scene as SceneEntity;
await activateScene(this.hass, scene.entity_id);
showToast(this, {
@ -149,38 +170,28 @@ class HaSceneDashboard extends LitElement {
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 {
return [
haStyle,
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 {
position: fixed;
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-item/paper-item-body";
import { HassEntity } from "home-assistant-js-websocket";
import "../../../layouts/hass-tabs-subpage";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../ha-config-section";
import {
css,
CSSResultArray,
customElement,
html,
LitElement,
property,
TemplateResult,
} 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 { 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 { HomeAssistant, Route } from "../../../types";
import { triggerScript } from "../../../data/script";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-script-picker")
class HaScriptPicker extends LitElement {
@ -35,91 +32,123 @@ class HaScriptPicker extends LitElement {
@property() public narrow!: boolean;
@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 {
return html`
<hass-tabs-subpage
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.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}>
<div slot="header">
${this.hass.localize("ui.panel.config.script.picker.header")}
</div>
<div slot="introduction">
${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>
</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>
<paper-icon-button
slot="toolbar-icon"
icon="hass:help-circle"
@click=${this._showHelp}
></paper-icon-button>
</hass-tabs-subpage-data-table>
<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>
`;
}
private async _runScript(ev) {
ev.stopPropagation();
const script = ev.currentTarget.script as HassEntity;
await triggerScript(this.hass, script.entity_id);
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 {
return [
haStyle,
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 {
position: fixed;
bottom: 16px;
@ -187,10 +212,6 @@ class HaScriptPicker extends LitElement {
right: auto;
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 {
border: 0;
width: 100%;
position: absolute;
height: calc(100% - 64px);
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 { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { findEntities } from "../common/find-entites";
import { LovelaceConfig } from "../../../data/lovelace";
import { fireEvent } from "../../../common/dom/fire_event";
const ICONS = {
armed_away: "hass:shield-lock",
@ -50,22 +50,21 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
public static getStubConfig(
hass: HomeAssistant,
lovelaceConfig: LovelaceConfig,
entities?: string[],
entitiesFill?: string[]
) {
entities: string[],
entitiesFallback: string[]
): AlarmPanelCardConfig {
const includeDomains = ["alarm_control_panel"];
const maxEntities = 1;
const foundEntities = findEntities(
hass,
lovelaceConfig,
maxEntities,
entities,
entitiesFill,
entitiesFallback,
includeDomains
);
return {
type: "alarm-panel",
states: ["arm_home", "arm_away"],
entity: foundEntities[0] || "",
};
@ -173,6 +172,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
class="${classMap({ [stateObj.state]: true })}"
.icon="${ICONS[stateObj.state] || "hass:shield-outline"}"
.label="${this._stateIconLabel(stateObj.state)}"
@click=${this._handleMoreInfo}
></ha-label-badge>
<div id="armActions" class="actions">
${(stateObj.state === "disarmed"
@ -248,16 +248,22 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
}
private _handleActionClick(e: MouseEvent): void {
const input = this._input!;
const code =
input && input.value && input.value.length > 0 ? input.value : "";
const input = this._input;
callAlarmAction(
this.hass!,
this._config!.entity,
(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 {
@ -281,6 +287,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
position: absolute;
right: 12px;
top: 12px;
cursor: pointer;
}
.disarmed {

View File

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

View File

@ -15,10 +15,11 @@ class HuiConditionalCard extends HuiConditionalBase implements LovelaceCard {
return document.createElement("hui-conditional-card-editor");
}
public static getStubConfig(): object {
public static getStubConfig(): ConditionalCardConfig {
return {
type: "conditional",
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 { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { LovelaceConfig } from "../../../data/lovelace";
import { findEntities } from "../common/find-entites";
@customElement("hui-entities-card")
@ -41,21 +40,19 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
public static getStubConfig(
hass: HomeAssistant,
lovelaceConfig: LovelaceConfig,
entities?: string[],
entitiesFill?: string[]
) {
entities: string[],
entitiesFallback: string[]
): EntitiesCardConfig {
const maxEntities = 3;
const foundEntities = findEntities(
hass,
lovelaceConfig,
maxEntities,
entities,
entitiesFill,
entitiesFallback,
["light", "switch", "sensor"]
);
return { title: "My Title", entities: foundEntities };
return { type: "entities", title: "My Title", entities: foundEntities };
}
@property() private _config?: EntitiesCardConfig;
@ -188,6 +185,9 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
static get styles(): CSSResult {
return css`
ha-card {
height: 100%;
}
.card-header {
display: flex;
justify-content: space-between;

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import "../../../components/ha-card";
import { LovelaceCard, LovelaceCardEditor } from "../types";
import { styleMap } from "lit-html/directives/style-map";
import { IframeCardConfig } from "./types";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
@customElement("hui-iframe-card")
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");
}
public static getStubConfig(): object {
return { url: "https://www.home-assistant.io", aspect_ratio: "50%" };
public static getStubConfig(): IframeCardConfig {
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;
public getCardSize(): number {
@ -51,17 +59,29 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
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`
<ha-card .header="${this._config.title}">
<div
id="root"
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>
</ha-card>
`;
@ -69,6 +89,15 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
static get styles(): CSSResult {
return css`
:host([ispanel]) ha-card {
width: 100%;
height: 100%;
}
:host([ispanel][editMode]) ha-card {
height: calc(100% - 51px);
}
ha-card {
overflow: hidden;
}
@ -78,6 +107,10 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
position: relative;
}
:host([ispanel]) #root {
height: 100%;
}
iframe {
position: absolute;
border: none;

View File

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

View File

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

View File

@ -29,8 +29,9 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
return document.createElement("hui-markdown-card-editor");
}
public static getStubConfig(): object {
public static getStubConfig(): MarkdownCardConfig {
return {
type: "markdown",
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).",
};

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ import { PictureEntityCardConfig } from "./types";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { actionHandler } from "../common/directives/action-handler-directive";
import { hasAction } from "../common/has-action";
import { ActionHandlerEvent, LovelaceConfig } from "../../../data/lovelace";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { handleAction } from "../common/handle-action";
import { findEntities } from "../common/find-entites";
@ -42,21 +42,20 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
public static getStubConfig(
hass: HomeAssistant,
lovelaceConfig: LovelaceConfig,
entities?: string[],
entitiesFill?: string[]
): object {
entities: string[],
entitiesFallback: string[]
): PictureEntityCardConfig {
const maxEntities = 1;
const foundEntities = findEntities(
hass,
lovelaceConfig,
maxEntities,
entities,
entitiesFill,
entitiesFallback,
["light", "switch"]
);
return {
type: "picture-entity",
entity: foundEntities[0] || "",
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 { actionHandler } from "../common/directives/action-handler-directive";
import { hasAction } from "../common/has-action";
import { ActionHandlerEvent, LovelaceConfig } from "../../../data/lovelace";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { handleAction } from "../common/handle-action";
import { findEntities } from "../common/find-entites";
@ -46,17 +46,15 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
public static getStubConfig(
hass: HomeAssistant,
lovelaceConfig: LovelaceConfig,
entities?: string[],
entitiesFill?: string[]
entities: string[],
entitiesFallback: string[]
): PictureGlanceCardConfig {
const maxEntities = 2;
const foundEntities = findEntities(
hass,
lovelaceConfig,
maxEntities,
entities,
entitiesFill,
entitiesFallback,
["sensor", "binary_sensor"]
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,18 +50,18 @@ const addEntities = (entities: Set<string>, obj) => {
if (obj.card) {
addEntities(entities, obj.card);
}
if (obj.cards) {
if (obj.cards && Array.isArray(obj.cards)) {
obj.cards.forEach((card) => addEntities(entities, card));
}
if (obj.elements) {
if (obj.elements && Array.isArray(obj.elements)) {
obj.elements.forEach((card) => addEntities(entities, card));
}
if (obj.badges) {
if (obj.badges && Array.isArray(obj.badges)) {
obj.badges.forEach((badge) => addEntityId(entities, badge));
}
};
export const computeUsedEntities = (config) => {
export const computeUsedEntities = (config: LovelaceConfig): Set<string> => {
const entities = new Set<string>();
config.views.forEach((view) => addEntities(entities, view));
return entities;
@ -70,13 +70,26 @@ export const computeUsedEntities = (config) => {
export const computeUnusedEntities = (
hass: HomeAssistant,
config: LovelaceConfig
): string[] => {
): Set<string> => {
const usedEntities = computeUsedEntities(config);
return Object.keys(hass.states)
.filter(
(entity) =>
!usedEntities.has(entity) &&
!EXCLUDED_DOMAINS.includes(entity.split(".", 1)[0])
)
.sort();
const unusedEntities = calcUnusedEntities(hass, usedEntities);
return unusedEntities;
};
export const calcUnusedEntities = (
hass: HomeAssistant,
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 { LovelaceConfig } from "../../../data/lovelace";
import { computeDomain } from "../../../common/entity/compute_domain";
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 = (
hass: HomeAssistant,
lovelaceConfig: LovelaceConfig,
maxEntities: number,
entities?: string[],
entitiesFill?: string[],
entities: string[],
entitiesFallback: string[],
includeDomains?: string[],
entityFilter?: (stateObj: HassEntity) => boolean
) => {
let entityIds: string[];
entityIds = !entities?.length
? computeUnusedEntities(hass, lovelaceConfig)
: entities;
const conditions: Array<(value: string) => boolean> = [];
if (includeDomains && includeDomains.length) {
entityIds = entityIds.filter((eid) =>
includeDomains!.includes(computeDomain(eid))
);
if (includeDomains?.length) {
conditions.push((eid) => includeDomains!.includes(computeDomain(eid)));
}
if (entityFilter) {
entityIds = entityIds.filter(
conditions.push(
(eid) => hass.states[eid] && entityFilter(hass.states[eid])
);
}
if (entityIds.length < (maxEntities || 1)) {
let fillEntityIds =
entitiesFill && entitiesFill.length
? entitiesFill
: [...computeUsedEntities(lovelaceConfig)];
entityIds = arrayFilter(entities, conditions, maxEntities);
if (includeDomains && includeDomains.length) {
fillEntityIds = fillEntityIds.filter((eid) =>
includeDomains!.includes(computeDomain(eid))
);
}
if (entityIds.length < maxEntities && entitiesFallback.length) {
const fallbackEntityIds = findEntities(
hass,
maxEntities - entityIds.length,
entitiesFallback,
[],
includeDomains,
entityFilter
);
if (entityFilter) {
fillEntityIds = fillEntityIds.filter(
(eid) => hass.states[eid] && entityFilter(hass.states[eid])
);
}
entityIds = [...entityIds, ...fillEntityIds];
entityIds.push(...fallbackEntityIds);
}
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 { handleAction } from "../common/handle-action";
import { EntitiesCardEntityConfig } from "../cards/types";
import { computeStateName } from "../../../common/entity/compute_state_name";
@customElement("hui-buttons-base")
export class HuiButtonsBase extends LitElement {
@ -31,9 +32,12 @@ export class HuiButtonsBase extends LitElement {
set hass(hass: HomeAssistant) {
this._hass = hass;
const entitiesShowingIcons = this.configEntities?.filter(
(entity) => entity.show_icon !== false
);
this._badges.forEach((badge, index: number) => {
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`
<div>
<state-badge
title=${computeTooltip(this._hass!, entityConf)}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(entityConf.hold_action),
hasDoubleClick: hasAction(entityConf.double_tap_action),
})}
.config=${entityConf}
.hass=${this._hass}
.stateObj=${stateObj}
.overrideIcon=${entityConf.icon}
.overrideImage=${entityConf.image}
stateColor
tabindex="0"
></state-badge>
<div
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(entityConf.hold_action),
hasDoubleClick: hasAction(entityConf.double_tap_action),
})}
.config=${entityConf}
tabindex="0"
>
${entityConf.show_icon !== false
? html`
<state-badge
title=${computeTooltip(this._hass!, entityConf)}
.hass=${this._hass}
.stateObj=${stateObj}
.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>
</div>
`;
@ -88,8 +103,10 @@ export class HuiButtonsBase extends LitElement {
.missing {
color: #fce588;
}
state-badge {
div {
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"
)}</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}">
${this.hass!.localize(
"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 {
showEditCardDialog(this, {
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 { HomeAssistant } from "../../../types";
import { fireEvent, HASSDomEvent } 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>;
}
}
import { fireEvent } from "../../../common/dom/fire_event";
@customElement("hui-theme-select-editor")
export class HuiThemeSelectEditor extends LitElement {
@ -30,32 +19,34 @@ export class HuiThemeSelectEditor extends LitElement {
@property() public hass?: HomeAssistant;
protected render(): TemplateResult {
const themes = ["Backend-selected", "default"].concat(
Object.keys(this.hass!.themes.themes).sort()
);
return html`
<paper-dropdown-menu
.label=${this.label ||
this.hass!.localize("ui.panel.lovelace.editor.card.generic.theme") +
" (" +
this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
) +
")"}
`${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`}
dynamic-align
@value-changed="${this._changed}"
>
<paper-listbox
slot="dropdown-content"
.selected="${this.value}"
.selected=${this.value}
attr-for-selected="theme"
@iron-select=${this._changed}
>
${themes.map((theme) => {
return html`
<paper-item theme="${theme}">${theme}</paper-item>
`;
})}
<paper-item theme="remove"
>${this.hass!.localize(
"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-dropdown-menu>
`;
@ -70,11 +61,11 @@ export class HuiThemeSelectEditor extends LitElement {
}
private _changed(ev): void {
if (!this.hass || ev.target.value === "") {
if (!this.hass || ev.target.selected === "") {
return;
}
this.value = ev.target.value;
fireEvent(this, "theme-changed");
this.value = ev.target.selected === "remove" ? "" : ev.target.selected;
fireEvent(this, "value-changed", { value: this.value });
}
}

View File

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