UNPKG

lovelace-timer-bar-card

Version:

Progress bar display for Home Assistant timers

407 lines (355 loc) 16 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { LitElement, html, CSSResultGroup, TemplateResult, css, PropertyValues } from 'lit'; import { state, property } from "lit/decorators.js"; import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { HomeAssistant, hasConfigOrEntityChanged, computeStateDisplay } from 'custom-card-helpers'; import { findDuration, formatStartTime, timerTimeRemaining, timerTimePercent, findMode, stateMode, autoMode, tryDurationToSeconds, MIN_SYNC_ERROR, MAX_FIX_SYNC_ERROR, gatherEntitiesFromConfig, haveEntitiesChanged } from './helpers'; import { TimerBarEntityConfig, HassEntity, Translations, TimerBarConfig, Mode } from './types'; import { genericEntityRow, genericEntityRowStyles } from './ha-generic-entity-row'; import { createActionHandler, createHandleAction } from './helpers-actions'; import formatTime, { formatFromResolution } from './format-time'; export function fillConfig(config: TimerBarEntityConfig): TimerBarConfig { return { active_state: ['active', 'on', 'manual', 'program', 'once_program'], pause_state: 'paused', waiting_state: 'waiting', guess_mode: false, end_time: { attribute: 'end_time' }, start_time: { attribute: 'start_time' }, duration: { attribute: 'duration' }, remain_time: { attribute: 'remain_time' }, sync_issues: 'show', bar_width: 'calc(70% - 7em)', bar_height: '8px', text_width: '3.5em', bar_background: '#eee', bar_foreground: 'var(--mdc-theme-primary, #6200ee);', bar_radius: '0', layout: 'normal', resolution: 'seconds', ...config, translations: { scheduled_for: 'Scheduled for', once_program: 'Once Program', program: 'Program', manual: 'Manual', waiting: 'Waiting', ...config.translations, } }; } function tryTranslate(hass: HomeAssistant, state: string) { if (state === 'idle') return hass.localize('component.timer.state._.idle'); if (state === 'paused') return hass.localize('component.timer.state._.paused'); if (state === 'active') return hass.localize('component.timer.state._.active'); if (state === 'on') return hass.localize('component.switch.state._.on'); return; } export function localize(hass: HomeAssistant, state: string, stateObj?: HassEntity, translations?: Translations, capitalize = true) { if (!state) return ''; if (translations && translations[state]) return translations[state]; if (stateObj) { const translation = computeStateDisplay(hass.localize, stateObj, hass.locale!, state); if (translation !== state) return capitalize ? translation : translation.toLowerCase(); } const translation = tryTranslate(hass, state); if (translation) return capitalize ? translation : translation.toLowerCase(); if (!capitalize) return state return state[0].toUpperCase() + state.substring(1); } export class TimerBarEntityRow extends LitElement { @property() public hass?: HomeAssistant; @property() public config!: TimerBarEntityConfig; @state() private _interval?: number; @state() private _timeRemaining?: number; @state() private _previousClockCorrection: number = 0; @state() private _browserClockCorrection: number = 0; @state() private _error?: Error; @state() protected _warning?: TemplateResult; public disconnectedCallback(): void { super.disconnectedCallback(); this._clearInterval(); } public connectedCallback(): void { super.connectedCallback(); if (this.config) { const stateObj = this.hass?.states[this.config!.entity!]; this._startInterval(stateObj); } } protected _mode(): Mode { return findMode(this.hass!, this.config, this._browserClockCorrection); } protected render(): TemplateResult | void { const state: HassEntity | undefined = this.hass!.states[this.config.entity!]; if (this._error) return html`<hui-warning>${this._error.message}</hui-warning>`; let percent = 0; try { percent = timerTimePercent(this.hass!, this.config, state, this._browserClockCorrection) ?? 0; } catch (e) { return html`<hui-warning>${e}</hui-warning>`; } if (percent > 100) percent = 100; let activeConfig: TimerBarEntityConfig try { activeConfig = { ...this.modConfig, icon: this.modConfig.active_icon ?? this.modConfig.icon, }; } catch (e) { return html`<div>${e}</div>` } // Hide the pointer if tap action is none const pointer = activeConfig.tap_action?.action !== "none" ? "pointer" : ""; switch (this._mode()) { case 'active': return this._renderRow(activeConfig, html` ${this._renderBar(percent)} ${this._renderTime(pointer)} `); case 'pause': return this._renderRow(activeConfig, html` ${this._renderStatus(pointer, '')} ${this._renderTime(pointer)} `); case 'waiting': return this._renderRow(this.modConfig, html` ${this._renderStatus(pointer, formatStartTime(this.hass!, this.config, state))} `); default: const textHidden = (this.modConfig.text_width && parseInt(this.modConfig.text_width) === 0); const style = textHidden ? 'visibility: hidden' : ''; return this._renderRow(this.modConfig, html` <div class="text-content value ${pointer}" style=${style} @action=${createHandleAction(this.hass!, this.config)} .actionHandler=${createActionHandler(this.config)} >${this._renderState()}</div> `); } } protected _renderState(): string | TemplateResult { const state = this.hass!.states[this.config.entity!]; const value = this.config.state?.fixed.replace(/^pause$/, 'paused') ?? state.state; return this.localize(value, state) } protected localize(value: string, state: HassEntity, capitalize = true) { return localize(this.hass!, value, state, this.config.translations, capitalize) } protected _renderRow(config: TimerBarConfig, contents: TemplateResult) { const warning = this._warning ? html`<hui-warning>${this._warning}</hui-warning>` : ''; if (this.modConfig.full_row || this.modConfig.layout === 'full_row') return html`${warning}<div class="flex" @action=${createHandleAction(this.hass!, config)} .actionHandler=${createActionHandler(config)}> ${contents}</div>${this._renderDebug()}`; if (this.modConfig.layout === 'hide_name') config = { ...config, name: '' }; return html` ${warning} ${genericEntityRow(contents, this.hass, config)} ${this._renderDebug()} `; } protected _renderTime(pointer: string) { const format = this.modConfig.format ? this.modConfig.format : formatFromResolution(this._timeRemaining || 0, this.modConfig.resolution!) return html`<div class="text-content value ${pointer}" style=${this._textStyle()} @action=${createHandleAction(this.hass!, this.config)} .actionHandler=${createActionHandler(this.config)}> ${formatTime(this._timeRemaining || 0, format)} </div>`; } protected _renderStatus(pointer: string, content: TemplateResult | string) { const state = this.hass!.states[this.config.entity!]; const value = this.config.state?.fixed ?? state.state; return html` <div class="status ${pointer}" style=${this._statusStyle(!!content)} @action=${createHandleAction(this.hass!, this.config)} .actionHandler=${createActionHandler(this.config)}> ${this._renderState()} ${content} </div>`; } private get _bar_width() { if (this.modConfig.full_row || this.modConfig.layout === 'full_row') return `calc(100% - ${this.modConfig.text_width})`; if (this.modConfig.layout === 'hide_name') return 'auto'; return this.modConfig.bar_width; } protected _renderBar(percent: number) { if (this.modConfig.invert) percent = 100 - percent; // invert if the options say so let style: StyleInfo = { width: this._bar_width, direction: this.modConfig.bar_direction }; if (this.modConfig.layout === 'hide_name') style = { ...style, 'flex-grow': '1', 'margin-left': '8px' }; const containerStyle = styleMap(style); const bgStyle = this._barStyle('100%', this.modConfig.bar_background!); const fgStyle = this._barStyle(percent + "%", this.modConfig.bar_foreground!); const pointer = this.config.tap_action?.action !== "none" ? "pointer" : ""; return html`<div class="bar-container ${pointer}" style=${containerStyle} @action=${createHandleAction(this.hass!, this.config)} .actionHandler=${createActionHandler(this.config)}> <div class="bar" style=${bgStyle}> <div style=${fgStyle}> </div> </div>`; } protected _renderDebug() { if (!this.config.debug) return undefined; const state = this.hass!.states[this.config.entity!]; let stMode: string = 'err' let guess_mode: string = 'err' let remaining: number | undefined let warn_active = false let aoMode: string = 'err' let duration: string = 'err' let err: string = '' try { guess_mode = this.config.guess_mode ? 'guessed' : 'explicit'; remaining = this._mode() != 'idle' ? timerTimeRemaining(this.hass!, this.config, state, this._browserClockCorrection) : undefined warn_active = !!remaining && remaining > 0 && this._mode() != 'active'; stMode = stateMode(this.hass!, this.config, this._browserClockCorrection) || 'N/A'; aoMode = autoMode(this.hass!, this.config, this._browserClockCorrection) || 'N/A'; duration = findDuration(this.hass!, this.config, state) } catch (e) { err = 'Error calculating duration:' + e } return html`<code> State: ${state?.state ?? 'No entity specified'} (state mode = ${stMode})<br> Mode: ${this._mode()} (${guess_mode}; guess mode produces ${aoMode})<br> Duration: ${duration} second<br> Time remaining: ${remaining}<br> Counter: ${this._timeRemaining}<br> ${warn_active ? html`<b>Did you set active_state?</b>` : ''} ${err ? err : ''} ${state ? html`<small>Attr: ${JSON.stringify(state.attributes)}</small>` : ''} </code>`; } /** Check if Home Assistant and local time are out of sync */ private _checkForSyncIssues(oldHass?: HomeAssistant) { if (!oldHass || !this.config.entity) return; const newState = this.hass!.states[this.config.entity]; if (oldHass.states[this.config.entity] == newState) return; const homeAssistantAhead = Date.parse(newState.last_changed) - Date.now(); if (this.config.sync_issues == 'show' && Math.abs(homeAssistantAhead) > MIN_SYNC_ERROR) { // Only show sync errors after the clock correction can be precisely measured twice. if (Math.abs(homeAssistantAhead - this._previousClockCorrection) < MIN_SYNC_ERROR) { this._warning = this._generateSyncWarning(homeAssistantAhead); } this._previousClockCorrection = homeAssistantAhead; } else if (this.config.sync_issues == 'fix' && Math.abs(homeAssistantAhead) < MAX_FIX_SYNC_ERROR) { this._browserClockCorrection = homeAssistantAhead; } } private _generateSyncWarning(homeAssistantAhead: number) { const phrase = homeAssistantAhead > 0 ? 'ahead of' : 'behind'; const difference = Math.abs(homeAssistantAhead) / 1000; const message = `Detected sync issues: Home Assistant clock is ${difference}s ${phrase} app time.`; return html`${message} <a href="https://github.com/rianadon/timer-bar-card#sync-issues">Learn more.</a>`; } protected shouldUpdate(changedProps: PropertyValues): boolean { if (!this.config) return false; if (changedProps.has('_timeRemaining')) return true; const oldHass = changedProps.get('hass') as HomeAssistant | undefined; this._checkForSyncIssues(oldHass) if (!oldHass || !this.hass) return true; const entities = gatherEntitiesFromConfig(this.config) return haveEntitiesChanged(entities, oldHass, this.hass) } protected updated(changedProps: PropertyValues) { super.updated(changedProps); if (changedProps.has("hass")) { const stateObj = this.hass!.states[this.config!.entity!]; this._startInterval(stateObj); } } private _clearInterval(): void { if (this._interval) { window.clearInterval(this._interval); this._interval = undefined; } } private _startInterval(stateObj: HassEntity | undefined): void { this._clearInterval(); this._calculateRemaining(stateObj); if (this._mode() === 'active') { this._interval = window.setInterval( () => this._calculateRemaining(stateObj), 1000 ); } } private _calculateRemaining(stateObj: HassEntity | undefined): void { try { this._timeRemaining = this._mode() != 'idle' ? timerTimeRemaining(this.hass!, this.config, stateObj, this._browserClockCorrection) : undefined; this._error = undefined; } catch (e) { console.error(e); this._error = e as Error; } } private _barStyle(width: string, background: string) { return styleMap({ width, background, height: this.modConfig.bar_height, 'border-radius': this.modConfig.bar_radius, }); } private _textStyle() { return styleMap({ width: this.modConfig.text_width, 'flex-shrink': '0', }); } private _statusStyle(includeText?: boolean) { let width = this._bar_width; if (includeText) width = `calc(${this._bar_width} + ${this.modConfig.text_width})`; return styleMap({ width, color: 'var(--secondary-text-color, #eee)' }); } static get styles(): CSSResultGroup { return [css` :host { display: flex; flex-direction: column; justify-content: center; } .pointer { cursor: pointer; } .flex { display: flex; height: 40px; align-items: center; justify-content: flex-end; } .bar-container { min-height: 1.5em; display: flex; flex-shrink: 0; align-items: center; } .bar { margin-top: 2px; overflow: hidden; } .status { line-height: 1.5em; flex-shrink: 0; } .text-content { text-align: right; text-align: end; overflow: hidden; } code { display: block; background-color: var(--secondary-background-color); margin: 0.5em 0 0 0; padding: 0.7rem; font-size: 0.9em; word-break: break-all; } `, genericEntityRowStyles]; } protected get modConfig(): TimerBarEntityConfig { if (!this.config.modifications) return this.config; const state: HassEntity | undefined = this.hass!.states[this.config.entity!]; const remaining = (this._mode() != 'idle' ? timerTimeRemaining(this.hass!, this.config, state, this._browserClockCorrection) : undefined) ?? Infinity; const elapsed = (findDuration(this.hass!, this.config, state) ?? 0) - remaining; const percentElapsed = timerTimePercent(this.hass!, this.config, state, this._browserClockCorrection) ?? 0; const percentRemaining = 100 - percentElapsed let config = this.config; for (const mod of this.config.modifications) { // @ts-ignore. Warn on using old config syntax if (mod.greater_than_eq || mod.greater_than) { throw new Error('Mod format has changed! See the release notes and readme for details') } if (mod.remaining && typeof mod.remaining === 'string' && mod.remaining.endsWith('%')) { if (percentRemaining <= parseFloat(mod.remaining)) config = { ...config, ...mod }; } else if (mod.remaining) { if (remaining <= tryDurationToSeconds(mod.remaining, 'remaining')) config = { ...config, ...mod }; } else if (mod.elapsed && typeof mod.elapsed === 'string' && mod.elapsed.endsWith('%')) { if (percentElapsed >= parseFloat(mod.elapsed)) config = { ...config, ...mod }; } else if (mod.elapsed) { if (elapsed >= tryDurationToSeconds(mod.elapsed, 'elapsed')) config = { ...config, ...mod }; } } return config; } }