Co-authored-by: Philip Allgaier <mail@spacegaier.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Zack Barett 2021-11-17 12:43:41 -06:00 committed by GitHub
parent a567312bdb
commit 4684979ae7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 726 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -0,0 +1,156 @@
import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, query } from "lit/decorators";
import { getEntity } from "../../../src/fake_data/entity";
import { provideHass } from "../../../src/fake_data/provide_hass";
import "../components/demo-cards";
const ENTITIES = [
getEntity("light", "bed_light", "on", {
friendly_name: "Bed Light",
}),
getEntity("switch", "bed_ac", "on", {
friendly_name: "Ecobee",
}),
getEntity("sensor", "bed_temp", "72", {
friendly_name: "Bedroom Temp",
device_class: "temperature",
unit_of_measurement: "°F",
}),
getEntity("light", "living_room_light", "off", {
friendly_name: "Living Room Light",
}),
getEntity("fan", "living_room", "on", {
friendly_name: "Living Room Fan",
}),
getEntity("sensor", "office_humidity", "73", {
friendly_name: "Office Humidity",
device_class: "humidity",
unit_of_measurement: "%",
}),
getEntity("light", "office", "on", {
friendly_name: "Office Light",
}),
getEntity("fan", "kitchen", "on", {
friendly_name: "Second Office Fan",
}),
getEntity("binary_sensor", "kitchen_door", "on", {
friendly_name: "Office Door",
device_class: "door",
}),
];
// TODO: Update image here
const CONFIGS = [
{
heading: "Bedroom",
config: `
- type: area
area: bedroom
image: "/images/bed.png"
`,
},
{
heading: "Living Room",
config: `
- type: area
area: living_room
image: "/images/living_room.png"
`,
},
{
heading: "Office",
config: `
- type: area
area: office
image: "/images/office.jpg"
`,
},
{
heading: "Kitchen",
config: `
- type: area
area: kitchen
image: "/images/kitchen.png"
`,
},
];
@customElement("demo-hui-area-card")
class DemoArea extends LitElement {
@query("#demos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`<demo-cards id="demos" .configs=${CONFIGS}></demo-cards>`;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
hass.mockWS("config/area_registry/list", () => [
{
name: "Bedroom",
area_id: "bedroom",
},
{
name: "Living Room",
area_id: "living_room",
},
{
name: "Office",
area_id: "office",
},
{
name: "Second Office",
area_id: "kitchen",
},
]);
hass.mockWS("config/device_registry/list", () => []);
hass.mockWS("config/entity_registry/list", () => [
{
area_id: "bedroom",
entity_id: "light.bed_light",
},
{
area_id: "bedroom",
entity_id: "switch.bed_ac",
},
{
area_id: "bedroom",
entity_id: "sensor.bed_temp",
},
{
area_id: "living_room",
entity_id: "light.living_room_light",
},
{
area_id: "living_room",
entity_id: "fan.living_room",
},
{
area_id: "office",
entity_id: "light.office",
},
{
area_id: "office",
entity_id: "sensor.office_humidity",
},
{
area_id: "kitchen",
entity_id: "fan.kitchen",
},
{
area_id: "kitchen",
entity_id: "binary_sensor.kitchen_door",
},
]);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-hui-area-card": DemoArea;
}
}

View File

@ -172,6 +172,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
{
area_id: "",
name: this.hass.localize("ui.components.area-picker.no_areas"),
picture: null,
},
];
}
@ -295,6 +296,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
{
area_id: "",
name: this.hass.localize("ui.components.area-picker.no_match"),
picture: null,
},
];
}
@ -306,6 +308,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
{
area_id: "add_new",
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
},
];
}

View File

@ -7,7 +7,7 @@ import { HomeAssistant } from "../types";
export interface AreaRegistryEntry {
area_id: string;
name: string;
picture?: string;
picture: string | null;
}
export interface AreaRegistryEntryMutableParams {

View File

@ -0,0 +1,431 @@
import "@material/mwc-ripple";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { navigate } from "../../../common/navigate";
import "../../../components/entity/state-badge";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon";
import {
AreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { forwardHaptic } from "../../../data/haptics";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { toggleEntity } from "../common/entity/toggle-entity";
import "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } from "../types";
import { AreaCardConfig } from "./types";
const SENSOR_DOMAINS = new Set(["sensor", "binary_sensor"]);
const SENSOR_DEVICE_CLASSES = new Set([
"temperature",
"humidity",
"motion",
"door",
"aqi",
]);
const TOGGLE_DOMAINS = new Set(["light", "fan", "switch"]);
@customElement("hui-area-card")
export class HuiAreaCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import("../editor/config-elements/hui-area-card-editor");
return document.createElement("hui-area-card-editor");
}
public static getStubConfig(): AreaCardConfig {
return { type: "area", area: "" };
}
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: AreaCardConfig;
@state() private _entities?: EntityRegistryEntry[];
@state() private _devices?: DeviceRegistryEntry[];
@state() private _areas?: AreaRegistryEntry[];
private _memberships = memoizeOne(
(
areaId: string,
devicesInArea: Set<string>,
registryEntities: EntityRegistryEntry[],
states: HomeAssistant["states"]
) => {
const entitiesInArea = registryEntities
.filter(
(entry) =>
!entry.entity_category &&
(entry.area_id
? entry.area_id === areaId
: entry.device_id && devicesInArea.has(entry.device_id))
)
.map((entry) => entry.entity_id);
const sensorEntities: HassEntity[] = [];
const entitiesToggle: HassEntity[] = [];
for (const entity of entitiesInArea) {
const domain = computeDomain(entity);
if (!TOGGLE_DOMAINS.has(domain) && !SENSOR_DOMAINS.has(domain)) {
continue;
}
const stateObj: HassEntity | undefined = states[entity];
if (!stateObj) {
continue;
}
if (entitiesToggle.length < 3 && TOGGLE_DOMAINS.has(domain)) {
entitiesToggle.push(stateObj);
continue;
}
if (
sensorEntities.length < 3 &&
SENSOR_DOMAINS.has(domain) &&
stateObj.attributes.device_class &&
SENSOR_DEVICE_CLASSES.has(stateObj.attributes.device_class)
) {
sensorEntities.push(stateObj);
}
if (sensorEntities.length === 3 && entitiesToggle.length === 3) {
break;
}
}
return { sensorEntities, entitiesToggle };
}
);
private _area = memoizeOne(
(areaId: string | undefined, areas: AreaRegistryEntry[]) =>
areas.find((area) => area.area_id === areaId) || null
);
private _devicesInArea = memoizeOne(
(areaId: string | undefined, devices: DeviceRegistryEntry[]) =>
new Set(
areaId
? devices
.filter((device) => device.area_id === areaId)
.map((device) => device.id)
: []
)
);
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeAreaRegistry(this.hass!.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass!.connection, (devices) => {
this._devices = devices;
}),
subscribeEntityRegistry(this.hass!.connection, (entries) => {
this._entities = entries;
}),
];
}
public getCardSize(): number {
return 3;
}
public setConfig(config: AreaCardConfig): void {
if (!config.area) {
throw new Error("Area Required");
}
this._config = config;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || !this._config) {
return true;
}
if (
changedProps.has("_devicesInArea") ||
changedProps.has("_area") ||
changedProps.has("_entities")
) {
return true;
}
if (!changedProps.has("hass")) {
return false;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
!oldHass ||
oldHass.themes !== this.hass!.themes ||
oldHass.locale !== this.hass!.locale
) {
return true;
}
if (
!this._devices ||
!this._devicesInArea(this._config.area, this._devices) ||
!this._entities
) {
return false;
}
const { sensorEntities, entitiesToggle } = this._memberships(
this._config.area,
this._devicesInArea(this._config.area, this._devices),
this._entities,
this.hass.states
);
for (const stateObj of sensorEntities) {
if (oldHass!.states[stateObj.entity_id] !== stateObj) {
return true;
}
}
for (const stateObj of entitiesToggle) {
if (oldHass!.states[stateObj.entity_id] !== stateObj) {
return true;
}
}
return false;
}
protected render(): TemplateResult {
if (
!this._config ||
!this.hass ||
!this._areas ||
!this._devices ||
!this._entities
) {
return html``;
}
const { sensorEntities, entitiesToggle } = this._memberships(
this._config.area,
this._devicesInArea(this._config.area, this._devices),
this._entities,
this.hass.states
);
const area = this._area(this._config.area, this._areas);
if (area === null) {
return html`
<hui-warning>
${this.hass.localize("ui.card.area.area_not_found")}
</hui-warning>
`;
}
return html`
<ha-card
style=${styleMap({
"background-image": `url(${this.hass.hassUrl(area.picture)})`,
})}
>
<div class="container">
<div class="sensors">
${sensorEntities.map(
(stateObj) => html`
<span
.entity=${stateObj.entity_id}
@click=${this._handleMoreInfo}
>
<ha-state-icon .state=${stateObj}></ha-state-icon>
${computeDomain(stateObj.entity_id) === "binary_sensor"
? ""
: html`
${computeStateDisplay(
this.hass!.localize,
stateObj,
this.hass!.locale
)}
`}
</span>
`
)}
</div>
<div class="bottom">
<div
class="name ${this._config.navigation_path ? "navigate" : ""}"
@click=${this._handleNavigation}
>
${area.name}
</div>
<div class="buttons">
${entitiesToggle.map(
(stateObj) => html`
<ha-icon-button
class=${classMap({
off: stateObj.state === "off",
})}
.entity=${stateObj.entity_id}
.actionHandler=${actionHandler({
hasHold: true,
})}
@action=${this._handleAction}
>
<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
stateColor
></state-badge>
</ha-icon-button>
`
)}
</div>
</div>
</div>
</ha-card>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._config || !this.hass) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldConfig = changedProps.get("_config") as AreaCardConfig | undefined;
if (
(changedProps.has("hass") &&
(!oldHass || oldHass.themes !== this.hass.themes)) ||
(changedProps.has("_config") &&
(!oldConfig || oldConfig.theme !== this._config.theme))
) {
applyThemesOnElement(this, this.hass.themes, this._config.theme);
}
}
private _handleMoreInfo(ev) {
const entity = (ev.currentTarget as any).entity;
fireEvent(this, "hass-more-info", { entityId: entity });
}
private _handleNavigation() {
if (this._config!.navigation_path) {
navigate(this._config!.navigation_path);
}
}
private _handleAction(ev: ActionHandlerEvent) {
const entity = (ev.currentTarget as any).entity as string;
if (ev.detail.action === "hold") {
fireEvent(this, "hass-more-info", { entityId: entity });
} else if (ev.detail.action === "tap") {
toggleEntity(this.hass, entity);
forwardHaptic("light");
}
}
static get styles(): CSSResultGroup {
return css`
ha-card {
overflow: hidden;
position: relative;
padding-bottom: 56.25%;
background-size: cover;
}
.container {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.sensors {
color: white;
font-size: 18px;
flex: 1;
padding: 16px;
--mdc-icon-size: 28px;
cursor: pointer;
}
.name {
color: white;
font-size: 24px;
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 8px 8px 16px;
}
.name.navigate {
cursor: pointer;
}
state-badge {
--ha-icon-display: inline;
}
ha-icon-button {
color: white;
background-color: var(--area-button-color, rgb(175, 175, 175, 0.5));
border-radius: 50%;
margin-left: 8px;
--mdc-icon-button-size: 44px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-area-card": HuiAreaCard;
}
}

View File

@ -76,6 +76,11 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
state_color?: boolean;
}
export interface AreaCardConfig extends LovelaceCardConfig {
area: string;
navigation_path?: string;
}
export interface ButtonCardConfig extends LovelaceCardConfig {
entity?: string;
name?: string;

View File

@ -33,6 +33,7 @@ const ALWAYS_LOADED_TYPES = new Set([
const LAZY_LOAD_TYPES = {
"alarm-panel": () => import("../cards/hui-alarm-panel-card"),
area: () => import("../cards/hui-area-card"),
error: () => import("../cards/hui-error-card"),
"empty-state": () => import("../cards/hui-empty-state-card"),
"energy-usage-graph": () =>

View File

@ -0,0 +1,119 @@
import "@polymer/paper-input/paper-input";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-area-picker";
import { HomeAssistant } from "../../../../types";
import { AreaCardConfig } from "../../cards/types";
import "../../components/hui-theme-select-editor";
import { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { EditorTarget } from "../types";
import { configElementStyle } from "./config-elements-style";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
area: optional(string()),
navigation_path: optional(string()),
theme: optional(string()),
})
);
@customElement("hui-area-card-editor")
export class HuiAreaCardEditor
extends LitElement
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: AreaCardConfig;
public setConfig(config: AreaCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
get _area(): string {
return this._config!.area || "";
}
get _navigation_path(): string {
return this._config!.navigation_path || "";
}
get _theme(): string {
return this._config!.theme || "";
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<div class="card-config">
<ha-area-picker
.hass=${this.hass}
.value=${this._area}
.placeholder=${this._area}
.configValue=${"area"}
.label=${this.hass.localize("ui.dialogs.entity_registry.editor.area")}
@value-changed=${this._valueChanged}
></ha-area-picker>
<paper-input
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.action-editor.navigation_path"
)}
.value=${this._navigation_path}
.configValue=${"navigation_path"}
@value-changed=${this._valueChanged}
>
</paper-input>
<hui-theme-select-editor
.hass=${this.hass}
.value=${this._theme}
.configValue=${"theme"}
@value-changed=${this._valueChanged}
></hui-theme-select-editor>
</div>
`;
}
private _valueChanged(ev: CustomEvent): void {
if (!this._config || !this.hass) {
return;
}
const target = ev.target! as EditorTarget;
const value = ev.detail.value;
if (this[`_${target.configValue}`] === value) {
return;
}
let newConfig;
if (target.configValue) {
if (!value) {
newConfig = { ...this._config };
delete newConfig[target.configValue!];
} else {
newConfig = {
...this._config,
[target.configValue!]: value,
};
}
}
fireEvent(this, "config-changed", { config: newConfig });
}
static get styles(): CSSResultGroup {
return configElementStyle;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-area-card-editor": HuiAreaCardEditor;
}
}

View File

@ -89,6 +89,9 @@ export const coreCards: Card[] = [
type: "weather-forecast",
showElement: true,
},
{
type: "area",
},
{
type: "conditional",
},

View File

@ -116,6 +116,9 @@
"arm_vacation": "Arm vacation",
"arm_custom_bypass": "Custom bypass"
},
"area": {
"area_not_found": "Area not found."
},
"automation": {
"last_triggered": "Last triggered",
"trigger": "Run Actions"
@ -3212,6 +3215,10 @@
"available_states": "Available States",
"description": "The Alarm Panel card allows you to Arm and Disarm your alarm control panel integrations."
},
"area": {
"name": "Area",
"description": "The Area card automatically displays entities of a specific area."
},
"calendar": {
"name": "Calendar",
"description": "The Calendar card displays a calendar including day, week and list views",