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, { localizeValue } from './localize'; import styles from './styles.css'; import buildConfig from './config'; import { Template, LawnMowerCardAction, LawnMowerCardConfig, LawnMowerEntity, HassEntity, LawnMowerEntityState, LawnMowerServiceCallParams, LawnMowerActionParams, } from './types'; import DEFAULT_IMAGE from './lawn-mower.svg'; registerTemplates(); // String in the right side will be replaced by Rollup const PKG_VERSION = 'PKG_VERSION_VALUE'; console.info( `%c LAWN-MOWER-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('lawn-mower-card') export class LawnMowerCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private config!: LawnMowerCardConfig; @state() private requestInProgress = false; @state() private thumbUpdater: ReturnType | null = null; static get styles(): CSSResultGroup { return styles; } public static async getConfigElement() { await import('./editor'); return document.createElement('lawn-mower-card-editor'); } static getStubConfig(_: unknown, entities: string[]) { const [lawnMowerEntity] = entities.filter((eid) => eid.startsWith('lawn_mower'), ); return { entity: lawnMowerEntity ?? '', }; } get entity(): LawnMowerEntity | undefined { if (!this.hass || !this.config?.entity) { return undefined; } return this.hass.states[this.config.entity] as LawnMowerEntity; } get map(): HassEntity | null { if (!this.hass || !this.config.map) { return null; } return this.hass.states[this.config.map]; } public setConfig(config: LawnMowerCardConfig): void { this.config = buildConfig(config); } public getCardSize(): number { if (!this.config) { return 3; } return this.config.compact_view ? 3 : 8; } public shouldUpdate(changedProps: PropertyValues): boolean { if (!this.config) { return false; } return hasConfigOrEntityChanged(this, changedProps, false); } protected updated(changedProps: PropertyValues) { if (!this.config) { return; } if ( changedProps.get('hass') && this.config.entity && (changedProps.get('hass') as HomeAssistant).states[this.config.entity] .state !== this.hass.states[this.config.entity].state ) { this.requestInProgress = false; } } public connectedCallback() { super.connectedCallback(); if (!this.config) { return; } 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: LawnMowerCardAction) { const { service, service_data } = action; const [domain, name] = service.split('.'); this.hass.callService(domain, name, service_data); } private callLawnMowerService( service: ServiceCallRequest['service'], params: LawnMowerServiceCallParams = { request: true }, options: ServiceCallRequest['serviceData'] = {}, ) { if (!this.config?.entity) { return; } this.hass.callService('lawn_mower', service, { entity_id: this.config.entity, ...options, }); if (params.request) { this.requestInProgress = true; this.requestUpdate(); } } private handleSpeed(e: PointerEvent): void { const fan_speed = (e.target).getAttribute('value'); this.callLawnMowerService( 'set_fan_speed', { request: false }, { fan_speed }, ); } private handleLawnMowerAction( action: string, params: LawnMowerActionParams = { request: true }, ) { return () => { if (!this.config.actions[action]) { return this.callLawnMowerService( params.defaultService || action, params, ); } this.callService(this.config.actions[action]); }; } private getAttributes(entity: LawnMowerEntity) { const { status, state, activity } = entity.attributes; return { ...entity.attributes, // Use activity attribute (Gardena) or raw_activity (other brands) for detailed status raw_activity: entity.attributes.raw_activity ?? activity, // Prefer entity.state (HA mapped state like "docked", "mowing") over // attributes.status/state which may be device-level (e.g. Gardena "OK") status: status ?? state ?? entity.state, }; } private getBatteryIcon(level: number, isCharging: boolean): string { const prefix = isCharging ? 'mdi:battery-charging' : 'mdi:battery'; if (level > 90) return isCharging ? 'mdi:battery-charging-100' : 'mdi:battery'; if (level < 10) return isCharging ? 'mdi:battery-charging-outline' : 'mdi:battery-outline'; const iconLevel = Math.floor(level / 10) * 10; return `${prefix}-${iconLevel}`; } private findBatterySensor(): string | undefined { const mainEntity = this.config.entity; const entities = (this.hass as Record).entities as | Record> | undefined; const deviceId = entities?.[mainEntity]?.device_id; if (!deviceId) return undefined; const entries = Object.entries(entities || {}); for (const [id, e] of entries) { if ( (e as Record).device_id === deviceId && id.startsWith('sensor.') && this.hass.states[id]?.attributes?.device_class === 'battery' ) { return id; } } return undefined; } private renderBattery(): Template { let battery_level; let battery_icon; let entityId = this.config.battery; const isCharging = this.entity?.attributes?.battery_state === 'CHARGING'; if (entityId) { battery_level = Number(this.hass.states[entityId].state); if (isNaN(battery_level)) { return nothing; } battery_icon = this.getBatteryIcon(battery_level, isCharging); } else { ({ battery_level, battery_icon } = this.getAttributes(this.entity!)); // Calculate icon from battery_level if no battery_icon attribute (e.g. Gardena) if (!battery_icon && battery_level != null) { const level = Number(battery_level); if (isNaN(level)) { return nothing; } battery_icon = this.getBatteryIcon(level, isCharging); } // Auto-detect battery sensor on same device if no level found if (battery_level == null) { entityId = this.findBatterySensor(); if (entityId && this.hass.states[entityId]) { battery_level = Number(this.hass.states[entityId].state); if (!isNaN(battery_level)) { battery_icon = this.getBatteryIcon(battery_level, isCharging); } } } } return html`
${battery_level}%
`; } private renderTemperature(): Template { let value; let icon; const entityId = this.config.temperature; if (entityId) { value = Number(this.hass.states[entityId].state); if (isNaN(value)) { return nothing; } icon = 'mdi:thermometer'; } else { return nothing; } return html`
${value}°C
`; } private renderHumidity(): Template { let value; let icon; const entityId = this.config.humidity; if (entityId) { value = Number(this.hass.states[entityId].state); if (isNaN(value)) { return nothing; } icon = 'mdi:water-percent'; } else { return nothing; } return html`
${value}%
`; } private renderMapOrImage(state: LawnMowerEntityState): Template { if (!this.config.compact_view && this.map) { return this.map?.attributes?.entity_picture ? html` this.handleMore(this.config.map)} /> ` : nothing; } const src = this.config.image === 'default' ? DEFAULT_IMAGE : this.config.image; const animated = this.config.animated ? ' animated' : ''; return html` `; } private renderStats(state: LawnMowerEntityState): 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 localizedState = localizeValue(state); const value = value_template ? html` ` : html`${localizedState}`; return html`
${value} ${unit}
${subtitle}
`; }, ); if (!stats.length) { return nothing; } return html`
${stats}
`; } private renderName(): Template { const { friendly_name } = this.getAttributes(this.entity!); if (!this.config.show_name) { return nothing; } return html`
${friendly_name}
`; } private renderStatus(): Template { if (!this.config.show_status) { return nothing; } const { status, raw_activity } = this.getAttributes(this.entity!); let actualStatus = status; if (raw_activity) { //use raw_activity instead of status because it is more detailed actualStatus = raw_activity; } const localizedStatus = localize(`status.${actualStatus.toLowerCase()}`) || actualStatus; const progressBar = html` `; return html`
${localizedStatus} ${this.requestInProgress ? progressBar : ''}
`; } private renderShortcuts(): Template { if (!this.config.show_shortcuts) { return nothing; } const buttons = this.config.shortcuts.map( ({ name, service, icon, service_data, link }) => { if (link) { return html` `; } else { const execute = () => { if (service) { return this.callService({ service, service_data }); } }; return html` `; } }, ); return html`
${buttons}
`; } private renderToolbar(state: LawnMowerEntityState): Template { const { raw_activity } = this.getAttributes(this.entity!); if (!this.config.show_toolbar) { return nothing; } switch (state) { case 'on': case 'auto': case 'spot': case 'edge': case 'single_room': case 'edgecut': case 'mowing': { return html`
${localize('common.pause')} ${localize('common.stop')} ${localize('common.return_to_base')}
`; } case 'paused': { return html`
${localize('common.continue')} ${localize('common.return_to_base')}
`; } case 'returning': { return html`
${localize('common.stop')}
`; } case 'docked': case 'idle': default: { const dockButton = html` ${localize('common.return_to_base')} `; const stopButton = html` ${localize('common.stop')} `; const locateButton = html` ${localize('common.locate')} `; const rawLower = raw_activity?.toLowerCase(); const isCharging = rawLower === 'charging_with_task_suspend' || rawLower === 'charging_with_queued_task' || rawLower === 'charging' || rawLower === 'ok_charging'; const isIdle = state === 'idle' || rawLower === 'standby' || rawLower === 'stopped_in_garden'; return html`
${localize('common.start')} ${this.config.actions['locate'] ? locateButton : ''} ${isCharging ? stopButton : ''} ${isIdle ? dockButton : ''}
`; } } } private renderUnavailable(): Template { return html`