lovelace-timer-bar-card
Version:
Progress bar display for Home Assistant timers
213 lines (173 loc) • 9.63 kB
text/typescript
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
}