lovelace-timer-bar-card
Version:
Progress bar display for Home Assistant timers
407 lines (355 loc) • 16 kB
text/typescript
/* 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 {
public hass?: HomeAssistant;
public config!: TimerBarEntityConfig;
private _interval?: number;
private _timeRemaining?: number;
private _previousClockCorrection: number = 0;
private _browserClockCorrection: number = 0;
private _error?: Error;
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}
=${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" =${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()}
=${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)}
=${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}
=${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;
}
}