UNPKG

vacuum-card

Version:

Vacuum cleaner card for Home Assistant Lovelace UI

520 lines (451 loc) 14.1 kB
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 buildConfig from './config'; import { Template, VacuumCardAction, VacuumCardConfig, VacuumEntity, HassEntity, VacuumEntityState, VacuumServiceCallParams, VacuumActionParams, } from './types'; import DEFAULT_IMAGE from './vacuum.svg'; registerTemplates(); // String in the right side will be replaced by Rollup const PKG_VERSION = 'PKG_VERSION_VALUE'; console.info( `%c VACUUM-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) {}, ); } @customElement('vacuum-card') export class VacuumCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private config!: VacuumCardConfig; @state() private requestInProgress = false; @state() private thumbUpdater: ReturnType<typeof setInterval> | null = null; static get styles(): CSSResultGroup { return styles; } public static async getConfigElement() { await import('./editor'); return document.createElement('vacuum-card-editor'); } static getStubConfig(_: unknown, entities: string[]) { const [vacuumEntity] = entities.filter((eid) => eid.startsWith('vacuum')); return { entity: vacuumEntity ?? '', }; } get entity(): VacuumEntity { return this.hass.states[this.config.entity] as VacuumEntity; } get map(): HassEntity | null { if (!this.hass || !this.config.map) { return null; } return this.hass.states[this.config.map]; } public setConfig(config: VacuumCardConfig): void { this.config = buildConfig(config); } public getCardSize(): number { return this.config.compact_view ? 3 : 8; } public shouldUpdate(changedProps: PropertyValues): boolean { return hasConfigOrEntityChanged(this, changedProps, false); } protected updated(changedProps: PropertyValues) { if ( changedProps.get('hass') && changedProps.get('hass').states[this.config.entity].state !== this.hass.states[this.config.entity].state ) { this.requestInProgress = false; } } public connectedCallback() { super.connectedCallback(); if (!this.config.compact_view && this.map) { this.requestUpdate(); this.thumbUpdater = setInterval( () => this.requestUpdate(), this.config.map_refresh * 1000, ); } } public disconnectedCallback() { super.disconnectedCallback(); if (this.map && this.thumbUpdater) { clearInterval(this.thumbUpdater); } } private handleMore(entityId: string = this.entity.entity_id): void { fireEvent( this, 'hass-more-info', { entityId, }, { bubbles: false, composed: true, }, ); } private callService(action: VacuumCardAction) { const { service, service_data, target } = action; const [domain, name] = service.split('.'); this.hass.callService(domain, name, service_data, target); } private callVacuumService( service: ServiceCallRequest['service'], params: VacuumServiceCallParams = { request: true }, options: ServiceCallRequest['serviceData'] = {}, ) { this.hass.callService('vacuum', service, { entity_id: this.config.entity, ...options, }); if (params.request) { this.requestInProgress = true; this.requestUpdate(); } } private handleSpeed(e: PointerEvent): void { const fan_speed = (<HTMLDivElement>e.target).getAttribute('value'); this.callVacuumService('set_fan_speed', { request: false }, { fan_speed }); } private handleVacuumAction( action: string, params: VacuumActionParams = { request: true }, ) { return () => { if (!this.config.actions[action]) { return this.callVacuumService(params.defaultService || action, params); } this.callService(this.config.actions[action]); }; } private getAttributes(entity: VacuumEntity) { const { status, state } = entity.attributes; return { ...entity.attributes, status: status ?? state ?? entity.state, }; } private renderSource(): Template { const { fan_speed: source, fan_speed_list: sources } = this.getAttributes( this.entity, ); if (!sources || !source) { return nothing; } const selected = sources.indexOf(source); return html` <div class="tip"> <ha-button-menu @click="${(e: Event) => e.stopPropagation()}"> <div slot="trigger"> <ha-icon icon="mdi:fan"></ha-icon> <span class="icon-title"> ${localize(`source.${source.toLowerCase()}`) || source} </span> </div> ${sources.map( (item, index) => html` <mwc-list-item ?activated=${selected === index} value=${item} @click=${this.handleSpeed} > ${localize(`source.${item.toLowerCase()}`) || item} </mwc-list-item> `, )} </ha-button-menu> </div> `; } private renderBattery(): Template { const { battery_level, battery_icon } = this.getAttributes(this.entity); return html` <div class="tip" @click="${() => this.handleMore()}"> <ha-icon icon="${battery_icon}"></ha-icon> <span class="icon-title">${battery_level}%</span> </div> `; } private renderMapOrImage(state: VacuumEntityState): Template { if (this.config.compact_view) { return nothing; } if (this.map) { return this.map && this.map.attributes.entity_picture ? html` <img class="map" src="${this.map.attributes.entity_picture}&v=${Date.now()}" @click=${() => this.handleMore(this.config.map)} /> ` : nothing; } const src = this.config.image === 'default' ? DEFAULT_IMAGE : this.config.image; return html` <img class="vacuum ${state}" src="${src}" @click="${() => this.handleMore()}" /> `; } private renderStats(state: VacuumEntityState): Template { const statsList = this.config.stats[state] || this.config.stats.default || []; 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 = html` <ha-template hass=${this.hass} template=${value_template} value=${state} variables=${{ value: state }} ></ha-template> `; 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> `; }, ); if (!stats.length) { return nothing; } return html`<div class="stats">${stats}</div>`; } private renderName(): Template { const { friendly_name } = this.getAttributes(this.entity); if (!this.config.show_name) { return nothing; } return html` <div class="vacuum-name">${friendly_name}</div> `; } private renderStatus(): Template { const { status } = this.getAttributes(this.entity); const localizedStatus = localize(`status.${status.toLowerCase()}`) || status; if (!this.config.show_status) { return nothing; } return html` <div class="status"> <span class="status-text" alt=${localizedStatus}> ${localizedStatus} </span> <ha-circular-progress .indeterminate=${this.requestInProgress} size="small" ></ha-circular-progress> </div> `; } private renderToolbar(state: VacuumEntityState): Template { if (!this.config.show_toolbar) { return nothing; } switch (state) { case 'on': case 'auto': case 'spot': case 'edge': case 'single_room': case 'cleaning': { return html` <div class="toolbar"> <paper-button @click="${this.handleVacuumAction('pause')}"> <ha-icon icon="hass:pause"></ha-icon> ${localize('common.pause')} </paper-button> <paper-button @click="${this.handleVacuumAction('stop')}"> <ha-icon icon="hass:stop"></ha-icon> ${localize('common.stop')} </paper-button> <paper-button @click="${this.handleVacuumAction('return_to_base')}"> <ha-icon icon="hass:home-map-marker"></ha-icon> ${localize('common.return_to_base')} </paper-button> </div> `; } case 'paused': { return html` <div class="toolbar"> <paper-button @click="${this.handleVacuumAction('resume', { defaultService: 'start', request: true, })}" > <ha-icon icon="hass:play"></ha-icon> ${localize('common.continue')} </paper-button> <paper-button @click="${this.handleVacuumAction('return_to_base')}"> <ha-icon icon="hass:home-map-marker"></ha-icon> ${localize('common.return_to_base')} </paper-button> </div> `; } case 'returning': { return html` <div class="toolbar"> <paper-button @click="${this.handleVacuumAction('resume', { defaultService: 'start', request: true, })}" > <ha-icon icon="hass:play"></ha-icon> ${localize('common.continue')} </paper-button> <paper-button @click="${this.handleVacuumAction('pause')}"> <ha-icon icon="hass:pause"></ha-icon> ${localize('common.pause')} </paper-button> </div> `; } case 'docked': case 'idle': default: { const buttons = this.config.shortcuts.map( ({ name, service, icon, service_data, target }) => { const execute = () => { if (service) { return this.callService({ service, service_data, target }); } }; return html` <ha-icon-button label="${name}" @click="${execute}"> <ha-icon icon="${icon}"></ha-icon> </ha-icon-button> `; }, ); const dockButton = html` <ha-icon-button label="${localize('common.return_to_base')}" @click="${this.handleVacuumAction('return_to_base')}" ><ha-icon icon="hass:home-map-marker"></ha-icon> </ha-icon-button> `; return html` <div class="toolbar"> <ha-icon-button label="${localize('common.start')}" @click="${this.handleVacuumAction('start')}" ><ha-icon icon="hass:play"></ha-icon> </ha-icon-button> <ha-icon-button label="${localize('common.locate')}" @click="${this.handleVacuumAction('locate', { request: false })}" ><ha-icon icon="mdi:map-marker"></ha-icon> </ha-icon-button> ${state === 'idle' ? dockButton : ''} <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(): Template { if (!this.entity) { return this.renderUnavailable(); } return html` <ha-card> <div class="preview"> <div class="header"> <div class="tips"> ${this.renderSource()} ${this.renderBattery()} </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> ${this.renderMapOrImage(this.entity.state)} <div class="metadata"> ${this.renderName()} ${this.renderStatus()} </div> ${this.renderStats(this.entity.state)} </div> ${this.renderToolbar(this.entity.state)} </ha-card> `; } } declare global { interface Window { customCards?: unknown[]; } } window.customCards = window.customCards || []; window.customCards.push({ preview: true, type: 'vacuum-card', name: localize('common.name'), description: localize('common.description'), });