UNPKG

lovelace-timer-bar-card

Version:

Progress bar display for Home Assistant timers

213 lines (173 loc) 9.63 kB
import { AttributeConfig, TimerBarConfig, HassEntity, Mode, TimerBarEntityConfig } from "./types"; import { durationToSeconds, formatTime, HomeAssistant } from "custom-card-helpers"; export const MIN_SYNC_ERROR = 500; // Allow local and HA times to be 500ms different export const MAX_FIX_SYNC_ERROR = 60000; // Allow sync fixes of up to 1hr /** Date.now(), but with a specified correction in ms. */ function now(correction: number) { return Date.now() + correction } export function tryDurationToSeconds(duration: string, field: string) { try { const seconds = durationToSeconds(duration); if (isNaN(seconds)) throw new Error(`Error parsing ${field} ${duration}: check it matches the format 0:10:00`); return seconds; } catch (e) { throw new Error(`Could not convert ${field}: ${duration} is not of format 0:10:00. If you are passing in a number, specify the units property.`); } } export function usesLastChanged(hass: HomeAssistant, config: TimerBarConfig, stateObj: HassEntity) { const duration = timeAttribute(hass, stateObj, config.duration); const remain_time = timeAttribute(hass, stateObj, config.remain_time); const start_time = attribute(hass, stateObj, config.start_time); const end_time = attribute(hass, stateObj, config.end_time); // Last changed is needed if at least 2 of duration, start time, and end time are undefined. return (!duration && !end_time) || (!duration && !start_time) || (!duration && !remain_time) || (!end_time && !start_time); } // (duration OR start + end) // AND // (start + duration OR end) // (duration + start + duration) OR (duration + end) OR (start + end + start) OR (start + end + end) // (start + duration) OR (end + duration) OR (start + end) OR (start + end) // (start + duration) OR (end + duration) OR (start + end) /** Find the duration of the timer. */ export function findDuration(hass: HomeAssistant, config: TimerBarConfig, stateObj: HassEntity | undefined) { const duration = timeAttribute(hass, stateObj, config.duration); if (duration) return duration; const start_time = attribute(hass, stateObj, config.start_time); const end_time = attribute(hass, stateObj, config.end_time); if (start_time && end_time) return (Date.parse(end_time) - Date.parse(start_time)) / 1000; if (!stateObj) throw new Error('If there is neither a duration nor start and end times, an entity is required.') if (end_time) return (Date.parse(end_time) - Date.parse(stateObj.last_changed)) / 1000; return null; } /** Calculate the most accurate estimate of time remaining for the timer. */ export const timerTimeRemaining = (hass: HomeAssistant, config: TimerBarConfig, stateObj: HassEntity | undefined, correction: number): undefined | number => { const madeActive = stateObj && Date.parse(stateObj.last_changed); if (stateObj?.attributes.remaining) { // For Home Assistant timers let timeRemaining = tryDurationToSeconds(stateObj.attributes.remaining, 'remaining'); if (isState(stateObj, config.active_state!, config)) { // Why timeRemaining and not duration? timeRemaining = Math.max(timeRemaining - (now(correction) - madeActive!) / 1000, 0); } return timeRemaining; } const end_time = attribute(hass, stateObj, config.end_time!); if (end_time) // For OpenSprinkler timers + others return (Date.parse(end_time) - now(correction)) / 1000; const start_time = attribute(hass, stateObj, config.start_time); const duration = timeAttribute(hass, stateObj, config.duration); if (start_time && duration) return (Date.parse(start_time) - now(correction)) / 1000 + duration; // Second-to-last fallback: remain time attribute const remain_time = timeAttribute(hass, stateObj, config.remain_time); if (remain_time != undefined) { return remain_time } if (!madeActive) throw new Error(`If you don't pass an entity, you must specify 2 time/duration properties`); // Final fallback: assume madeActive is the start time if (duration) return (madeActive - now(correction)) / 1000 + duration; return undefined; }; /** Calculate what percent of the timer's duration has passed. */ export const timerTimePercent = (hass: HomeAssistant, config: TimerBarConfig, stateObj: HassEntity | undefined, correction: number): undefined | number => { const remaining = timerTimeRemaining(hass, config, stateObj, correction); const duration = findDuration(hass, config, stateObj); if (!duration || !remaining) return undefined; return (duration - Math.floor(remaining)) / duration * 100; }; export const formatStartTime = (hass: HomeAssistant, config: TimerBarConfig, stateObj: HassEntity | undefined) => { const start_time = attribute(hass, stateObj, config.start_time); const start = new Date(start_time); const lang = JSON.parse(localStorage.getItem('selectedLanguage') || '"en"') || 'en'; return formatTime(start, lang); } export const isState = (stateObj: HassEntity | undefined, checkState: string | string[], config: TimerBarConfig) => { if (!stateObj) return false; const state = config.state_attribute ? stateObj.attributes[config.state_attribute] : stateObj.state; if (typeof checkState === 'string') return state === checkState; return checkState.includes(state); } function nullifyState(value: string) { if (value == "unavailable") return null return value } export const attribute = (hass: HomeAssistant, stateObj: HassEntity | undefined, attrib: AttributeConfig | undefined) => { if (!attrib) throw new Error('One of duration, remain_time, start_time, or end_time was not fully specified. Make sure you set entity, fixed, or attribute'); if ('fixed' in attrib) return attrib.fixed; if ('script' in attrib) { const match = hass.states[attrib.script].attributes['last_action']?.match(/[\d:]+$/) return match ? match[0] : undefined; } if ('entity' in attrib) { // Two cases: entity + attribute or entity + state if ('attribute' in attrib) return hass.states[attrib.entity].attributes[attrib.attribute] return nullifyState(hass.states[attrib.entity].state); } if (!stateObj) return undefined; // if (!stateObj) throw new Error(`Since there is no entity, ${JSON.stringify(attrib)} must specify fixed:.`); if ('state' in attrib) return nullifyState(stateObj!.state); return stateObj.attributes[attrib.attribute]; } const timeAttribute = (hass: HomeAssistant, stateObj: HassEntity | undefined, attrib: AttributeConfig | undefined) => { const duration = attribute(hass, stateObj, attrib); if (!duration) return duration; if (attrib!.units === 'hours' || attrib!.units === 'minutes' || attrib!.units === 'seconds') { const numeric = parseFloat(duration); if (isNaN(numeric)) throw new Error(`Expected duration ${duration} to be a number since units is ${attrib!.units}`); if (attrib!.units == 'hours') return numeric * 3600; if (attrib!.units == 'minutes') return numeric * 60; if (attrib!.units == 'seconds') return numeric * 1; } return tryDurationToSeconds(duration, 'duration'); } export function autoMode(hass: HomeAssistant, config: TimerBarEntityConfig, correction: number): Mode | undefined { // Disable if the last modified date is used and there is no end time // Otherwise, auto mode might be enabled when it's not supposed to be! const state = hass.states[config.entity!]; const end_time = attribute(hass, state, config.end_time); if (usesLastChanged(hass, config, state) && !end_time) return undefined; // Auto mode is not capable of determining whether the entity is paused or waiting const stMode = stateMode(hass, config, correction); if (stMode === 'pause' || stMode === 'waiting') return undefined; const duration = findDuration(hass, config, state); const remaining = timerTimeRemaining(hass, config, state, correction); if (!duration || !remaining) return undefined; if (remaining >= 0 && remaining <= duration + MIN_SYNC_ERROR) return 'active'; return undefined; } export function stateMode(hass: HomeAssistant, config: TimerBarEntityConfig, correction: number): Mode { if (config.state?.fixed) return config.state?.fixed.replace('paused', 'pause') as Mode; const state = hass.states[config.entity!]; if (isState(state, config.active_state!, config) && (timerTimeRemaining(hass, config, state, correction) || 0) > 0) return 'active'; if (isState(state, config.pause_state!, config)) return 'pause'; if (isState(state, config.waiting_state!, config)) return 'waiting'; return 'idle'; } export function findMode(hass: HomeAssistant, config: TimerBarEntityConfig, correction: number): Mode { if (config.guess_mode) return autoMode(hass, config, correction) || stateMode(hass, config, correction); return stateMode(hass, config, correction); } /** Returns an array of entities referenced by the config. * Useful for state updates **/ export function gatherEntitiesFromConfig(config: TimerBarEntityConfig): string[] { const entities: string[] = [] const addMaybe = (s: string | undefined) => s && entities.push(s) const addMaybeAttr = (c: AttributeConfig | undefined) => { if (c && 'entity' in c) entities.push(c.entity) if (c && 'script' in c) entities.push(c.script) } addMaybe(config.entity) addMaybeAttr(config.duration) addMaybeAttr(config.remain_time) addMaybeAttr(config.start_time) addMaybeAttr(config.end_time) return entities } export function haveEntitiesChanged(entities: string[], oldHass: HomeAssistant, newHass: HomeAssistant) { for (const entity of entities) { if (oldHass.states[entity] != newHass.states[entity]) return true } return false }