purifier-card
Version:
Air Purifier card for Home Assistant Lovelace UI
457 lines (387 loc) • 11.6 kB
text/typescript
import { CSSResultGroup, LitElement, PropertyValues, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import {
hasConfigOrEntityChanged,
fireEvent,
HomeAssistant,
ServiceCallRequest,
} from 'custom-card-helpers';
import registerTemplates from 'ha-template';
import get from 'lodash/get';
import localize from './localize';
import styles from './styles.css';
import workingImg from './images/purifier-working.gif';
import standbyImg from './images/purifier-standby.png';
import {
PurifierCardConfig,
PurifierEntity,
SliderValue,
Template,
} from './types';
import buildConfig from './config';
registerTemplates();
// String on the right side will be replaced by Rollup
const PKG_VERSION = 'PKG_VERSION_VALUE';
console.info(
`%c PURIFIER-CARD %c ${PKG_VERSION} `,
'color: white; background: blue; font-weight: 700;',
'color: blue; background: white; font-weight: 700;',
);
if (!customElements.get('ha-icon-button')) {
customElements.define(
'ha-icon-button',
class extends (customElements.get('paper-icon-button') ?? HTMLElement) {},
);
}
const SUPPORT_PRESET_MODE = 8;
('purifier-card')
export class PurifierCard extends LitElement {
({ attribute: false }) public hass!: HomeAssistant;
() private config!: PurifierCardConfig;
() private requestInProgress = false;
public static get styles(): CSSResultGroup {
return styles;
}
public static async getConfigElement() {
import('./editor');
return document.createElement('purifier-card-editor');
}
public static getStubConfig(
_: unknown,
entities: string[],
): Partial<PurifierCardConfig> {
const [purifierEntity] = entities.filter((eid) => eid.startsWith('fan'));
return {
entity: purifierEntity ?? '',
};
}
public setConfig(config: Partial<PurifierCardConfig>) {
this.config = buildConfig(config);
}
get entity(): PurifierEntity {
return this.hass.states[this.config.entity] as PurifierEntity;
}
public getCardSize() {
return 2;
}
protected shouldUpdate(changedProps: PropertyValues) {
return hasConfigOrEntityChanged(this, changedProps, false);
}
protected updated(changedProps: PropertyValues) {
if (
changedProps.get('hass') &&
changedProps.get('hass').states[this.config.entity] !==
this.hass.states[this.config.entity]
) {
this.requestInProgress = false;
}
}
private handleMore(entityId: string = this.entity.entity_id) {
fireEvent(
this,
'hass-more-info',
{
entityId,
},
{
bubbles: false,
composed: true,
},
);
}
private callService(
service: ServiceCallRequest['service'],
options: ServiceCallRequest['serviceData'] = {},
target?: ServiceCallRequest['target'],
request = true,
) {
const [domain, name] = service.split('.');
this.hass.callService(
domain,
name,
{
entity_id: this.config.entity,
...options,
},
target,
);
if (request) {
this.requestInProgress = true;
this.requestUpdate();
}
}
private handlePresetMode(event: PointerEvent) {
const preset_mode = (<HTMLDivElement>event.target).getAttribute('value');
this.callService('fan.set_preset_mode', { preset_mode });
}
private handlePercentage(event: CustomEvent<SliderValue>) {
const percentage = event.detail.value;
this.callService('fan.set_percentage', { percentage });
}
private renderPresetMode(): Template {
const {
attributes: { preset_mode, preset_modes, supported_features = 0 },
} = this.entity;
if (
!preset_mode ||
!this.config.show_preset_mode ||
!preset_modes ||
!(supported_features & SUPPORT_PRESET_MODE)
) {
return nothing;
}
const selected = preset_modes.indexOf(preset_mode);
return html`
<div class="preset-mode">
<ha-button-menu @click="${(e: PointerEvent) => e.stopPropagation()}">
<mmp-icon-button slot="trigger">
<ha-icon icon="mdi:fan"></ha-icon>
<span>
${localize(`preset_mode.${preset_mode}`) || preset_mode}
</span>
</mmp-icon-button>
${preset_modes.map(
(item, index) => html`
<mwc-list-item
?activated=${selected === index}
value=${item}
@click=${(e: PointerEvent) => this.handlePresetMode(e)}
>
${localize(`preset_mode.${item.toLowerCase()}`) || item}
</mwc-list-item>
`,
)}
</ha-button-menu>
</div>
`;
}
private renderAQI(): Template {
const { aqi = {} } = this.config;
const { entity_id, attribute, unit = 'AQI' } = aqi;
let value = '';
if (entity_id && attribute) {
value = get(this.hass.states[entity_id].attributes, attribute);
} else if (attribute) {
value = get(this.entity.attributes, attribute);
} else if (entity_id) {
value = this.hass.states[entity_id].state;
} else {
return nothing;
}
let prefix: Template = nothing;
const numericValue = Number(value);
if (numericValue < 10) {
prefix = html`<span class="number-off">00</span>`;
} else if (numericValue < 100) {
prefix = html`<span class="number-off">0</span>`;
}
return html`
<div class="current-aqi">
${prefix}<span class="number-on">${value}</span>
<sup>${unit}</sup>
</div>
`;
}
private renderSlider(): Template {
const {
state,
attributes: { percentage, percentage_step },
} = this.entity;
const disabled = state !== 'on';
const image = !disabled ? workingImg : standbyImg;
return html`
<div class="slider">
<round-slider
value=${percentage}
step=${percentage_step}
?disabled="${disabled}"
@value-changed=${(e: CustomEvent<SliderValue>) =>
this.handlePercentage(e)}
>
</round-slider>
<img src=${image} alt="purifier is ${state}" class="image" />
<div class="slider-center">
<div class="slider-content">${this.renderAQI()}</div>
<div class="slider-value">
${percentage ? `${percentage}%` : nothing}
</div>
</div>
</div>
`;
}
private renderControls(): Template {
return this.config.compact_view ? this.renderAQI() : this.renderSlider();
}
private renderName(): Template {
const {
attributes: { friendly_name },
} = this.entity;
if (!this.config.show_name) {
return nothing;
}
return html` <div class="friendly-name">${friendly_name}</div> `;
}
private renderState(): Template {
const { state } = this.entity;
const localizedState = localize(`state.${state}`) || state;
if (!this.config.show_state) {
return nothing;
}
return html`
<div class="state">
<span class="state-text" alt=${localizedState}>
${localizedState}
</span>
<ha-circular-progress
.indeterminate=${this.requestInProgress}
size="small"
></ha-circular-progress>
</div>
`;
}
private renderStats(): Template {
const statsList = this.config.stats || [];
const stats = statsList.map(
({ entity_id, attribute, value_template, unit, subtitle }) => {
if (!entity_id && !attribute) {
return nothing;
}
let state = '';
if (entity_id && attribute) {
state = get(this.hass.states[entity_id].attributes, attribute);
} else if (attribute) {
state = get(this.entity.attributes, attribute);
} else if (entity_id) {
state = this.hass.states[entity_id]?.state;
} else {
return nothing;
}
const value = state
? html`
<ha-template
hass=${this.hass}
template=${value_template}
value=${state}
variables=${{ value: state }}
></ha-template>
`
: nothing;
return html`
<div class="stats-block" @click="${() => this.handleMore(entity_id)}">
<span class="stats-value">${value}</span>
${unit}
<div class="stats-subtitle">${subtitle}</div>
</div>
`;
},
);
return stats.length ? html`<div class="stats">${stats}</div>` : nothing;
}
private renderToolbar(): Template {
const { shortcuts = [] } = this.config;
const { state, attributes } = this.entity;
if (!this.config.show_toolbar) {
return nothing;
}
const buttons = shortcuts.map(
({
name,
icon,
service,
service_data,
target,
preset_mode,
percentage,
}) => {
const execute = () => {
if (service) {
this.callService(service, target, service_data);
}
if (preset_mode) {
this.callService('fan.set_preset_mode', { preset_mode });
}
if (percentage) {
this.callService('fan.set_percentage', { percentage });
}
};
const isActive =
service ||
percentage === attributes.percentage ||
preset_mode === attributes.preset_mode;
const className = isActive ? 'active' : '';
return html`
<ha-icon-button
icon="${icon}"
title="${name}"
class="${className}"
@click="${execute}"
><ha-icon icon="${icon}"></ha-icon
></ha-icon-button>
`;
},
);
return html`
<div class="toolbar">
<ha-icon-button
icon="hass:power"
class="${state === 'on' ? 'active' : ''}"
title="${localize('common.toggle_power')}"
@click="${() => this.callService('fan.toggle')}"
><ha-icon icon="hass:power"></ha-icon>
</ha-icon-button>
<div class="fill-gap"></div>
${buttons}
</div>
`;
}
private renderUnavailable(): Template {
return html`
<ha-card>
<div class="preview not-available">
<div class="metadata">
<div class="not-available">
${localize('common.not_available')}
</div>
<div>
</div>
</ha-card>
`;
}
protected render() {
if (!this.entity) {
return this.renderUnavailable();
}
return html`
<ha-card>
<div class="preview">
<div class="header">
<div class="tips">${this.renderPresetMode()}</div>
<ha-icon-button
class="more-info"
icon="mdi:dots-vertical"
?more-info="true"
@click="${() => this.handleMore()}"
><ha-icon icon="mdi:dots-vertical"></ha-icon
></ha-icon-button>
</div>
<div class="controls">${this.renderControls()}</div>
<div class="metadata">${this.renderName()} ${this.renderState()}</div>
${this.renderStats()}
</div>
${this.renderToolbar()}
</ha-card>
`;
}
}
declare global {
interface Window {
customCards?: unknown[];
}
}
window.customCards = window.customCards || [];
window.customCards.push({
preview: true,
type: 'purifier-card',
name: localize('common.name'),
description: localize('common.description'),
});