Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld f1171afd0b Send normalized duration for timer presets
Presets are parsed leniently by createDurationData like other duration
inputs, but the raw config value was sent to timer.start. A malformed
preset such as 1:nope:00 rendered as 1 h yet was rejected by core. Store
the normalized seconds so the label and the service call always match.
2026-08-26 13:47:35 +02:00
Maarten LakerveldandCopilot Autofix powered by AI b045bd903a Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-26 13:47:16 +02:00
Maarten Lakerveld cac23916b4 Pulse timer red when it finishes
When a timer runs out or timer.finish is called, the tile icon and the
more-info countdown pulse red twice. Cancelling does not pulse: the new
timerJustFinished helper distinguishes the transitions via the
last_transition attribute. Honors prefers-reduced-motion.
2026-08-26 09:57:09 +02:00
Maarten Lakerveld 8562e068ea Add timer-actions and timer-presets card features
timer-actions shows start/pause/cancel/finish buttons (finish opt-in)
with state-aware disabling; start becomes restart while active.
timer-presets shows configurable one-tap durations that call timer.start.
Timer tile suggestions default to the timer-actions feature.
2026-08-26 09:57:09 +02:00
Maarten Lakerveld 0aaf64ae07 Modernize timer more-info dialog with state header and live countdown
Use ha-more-info-state-header with a ticking remaining-time display and
the standard more-info control layout. Adds timer to
DOMAINS_WITH_NEW_MORE_INFO, replacing the legacy state-card row.
2026-08-26 09:57:08 +02:00
Maarten Lakerveld a9239b123e Add timer data helpers for formatter-based display and duration serialization
Refactor computeDisplayTimer to take formatEntityState instead of hass so
context-migrated components can reuse it, add finishes_at to TimerEntity,
and add durationDataToTimerString for serializing duration input values.
2026-08-26 09:57:08 +02:00
20 changed files with 1497 additions and 147 deletions
+19
View File
@@ -16,6 +16,25 @@ const ENTITIES = [
duration: "0:05:00",
},
},
{
entity_id: "timer.active_timer",
state: "active",
attributes: {
friendly_name: "Active timer",
duration: "0:10:00",
remaining: "0:10:00",
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
},
},
{
entity_id: "timer.paused_timer",
state: "paused",
attributes: {
friendly_name: "Paused timer",
duration: "0:10:00",
remaining: "0:03:21",
},
},
];
@customElement("demo-more-info-timer")
@@ -0,0 +1,70 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HassEntity } from "home-assistant-js-websocket";
import { timerTimeRemaining } from "../../data/timer";
/**
* Tracks the live remaining time of a timer entity. While the timer is
* active, the host is re-rendered every second with an updated
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
*
* The host must call `setStateObj` whenever its timer entity changes.
*/
export class TimerRemainingTimeController implements ReactiveController {
public timeRemaining?: number;
private _host: ReactiveControllerHost;
private _stateObj?: HassEntity;
private _interval?: number;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public setStateObj(stateObj: HassEntity | undefined): void {
this._stateObj = stateObj;
this._startInterval();
}
public hostConnected(): void {
this._startInterval();
}
public hostDisconnected(): void {
this._clearInterval();
}
private _startInterval(): void {
this._clearInterval();
if (!this._stateObj) {
this.timeRemaining = undefined;
return;
}
this._calculateRemaining();
if (this._stateObj.state === "active") {
this._interval = window.setInterval(() => {
this._calculateRemaining();
this._host.requestUpdate();
}, 1000);
}
}
private _clearInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _calculateRemaining(): void {
this.timeRemaining = this._stateObj
? timerTimeRemaining(this._stateObj)
: undefined;
}
}
+43 -4
View File
@@ -4,8 +4,11 @@ import type {
HassEntityBase,
} from "home-assistant-js-websocket";
import { createDurationData } from "../common/datetime/create_duration_data";
import durationToSeconds from "../common/datetime/duration_to_seconds";
import durationToSeconds, {
durationDataToSeconds,
} from "../common/datetime/duration_to_seconds";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import type { FormatEntityStateFunc } from "../common/translations/entity-state";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
@@ -14,6 +17,8 @@ export type TimerEntity = HassEntityBase & {
duration: string;
remaining: string;
restore: boolean;
finishes_at?: string;
last_transition?: string;
};
};
@@ -64,6 +69,18 @@ export const deleteTimer = (hass: HomeAssistant, id: string) =>
timer_id: id,
});
// True when this state change is the timer completing: it ran out or
// timer.finish was called. Cancel also ends in "idle" but sets
// last_transition to "cancelled", so it does not match.
export const timerJustFinished = (
oldStateObj: HassEntity | undefined,
stateObj: HassEntity
): boolean =>
oldStateObj !== undefined &&
oldStateObj.state !== "idle" &&
stateObj.state === "idle" &&
stateObj.attributes.last_transition === "finished";
export const timerTimeRemaining = (
stateObj: HassEntity
): undefined | number => {
@@ -82,7 +99,7 @@ export const timerTimeRemaining = (
};
export const computeDisplayTimer = (
hass: HomeAssistant,
formatEntityState: FormatEntityStateFunc,
stateObj: HassEntity,
timeRemaining?: number
): string | null => {
@@ -91,18 +108,40 @@ export const computeDisplayTimer = (
}
if (stateObj.state === "idle" || timeRemaining === 0) {
return hass.formatEntityState(stateObj);
return formatEntityState(stateObj);
}
let display = secondsToDuration(timeRemaining || 0) || "0";
if (stateObj.state === "paused") {
display = `${display} (${hass.formatEntityState(stateObj)})`;
display = `${display} (${formatEntityState(stateObj)})`;
}
return display;
};
const leftPad = (num: number) => (num < 10 ? `0${num}` : `${num}`);
// Normalize duration data to whole-second hours/minutes/seconds fields, so
// out-of-range values ({seconds: 3600}) and fractional seconds cannot reach
// duration inputs or the serialized config. Timers only support whole seconds.
export const normalizeTimerDuration = (
data: HaDurationData
): HaDurationData => {
const totalSeconds = Math.floor(durationDataToSeconds(data));
return {
hours: Math.floor(totalSeconds / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
};
};
// Serialize duration data to the "H:MM:SS" format accepted by timer.start.
export const durationDataToTimerString = (data: HaDurationData): string => {
const { hours = 0, minutes = 0, seconds = 0 } = normalizeTimerDuration(data);
return `${hours}:${leftPad(minutes)}:${leftPad(seconds)}`;
};
// Prefill for the duration input: always the configured duration, independent
// of the live countdown. The field is meant to be edited, not to mirror the
// remaining time.
+1
View File
@@ -38,6 +38,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"siren",
"script",
"switch",
"timer",
"vacuum",
"valve",
"water_heater",
+152 -91
View File
@@ -2,6 +2,8 @@ import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { TimerRemainingTimeController } from "../../../common/controllers/timer-remaining-time-controller";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
@@ -9,10 +11,20 @@ import "../../../components/ha-button";
import type { HaButton } from "../../../components/ha-button";
import "../../../components/ha-duration-input";
import type { HaDurationData } from "../../../components/ha-duration-input";
import { apiContext } from "../../../data/context";
import { apiContext, formattersContext } from "../../../data/context";
import type { TimerEntity } from "../../../data/timer";
import { timerDurationData } from "../../../data/timer";
import type { HomeAssistantApi, ValueChangedEvent } from "../../../types";
import {
computeDisplayTimer,
timerDurationData,
timerJustFinished,
} from "../../../data/timer";
import type {
HomeAssistantApi,
HomeAssistantFormatters,
ValueChangedEvent,
} from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@customElement("more-info-timer")
class MoreInfoTimer extends LitElement {
@@ -26,8 +38,16 @@ class MoreInfoTimer extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state() private _duration?: HaDurationData;
@state() private _finished = false;
private _remainingTime = new TimerRemainingTimeController(this);
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Seed the field once from the configured duration and keep it static,
@@ -35,90 +55,117 @@ class MoreInfoTimer extends LitElement {
if (this._duration === undefined && this.stateObj) {
this._duration = timerDurationData(this.stateObj);
}
if (changedProps.has("stateObj")) {
this._remainingTime.setStateObj(this.stateObj);
if (
this.stateObj &&
timerJustFinished(changedProps.get("stateObj"), this.stateObj)
) {
this._finished = true;
}
}
}
private _finishedAnimationEnded(): void {
this._finished = false;
}
protected render() {
if (!this._localize || !this.stateObj) {
if (!this._localize || !this._formatters || !this.stateObj) {
return nothing;
}
const timerState = this.stateObj.state;
return html`
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${
timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing
}
${
timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing
}
${
timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
size="s"
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing
<ha-more-info-state-header
class=${classMap({ finished: this._finished })}
.stateObj=${this.stateObj}
.stateOverride=${
computeDisplayTimer(
this._formatters.formatEntityState,
this.stateObj,
this._remainingTime.timeRemaining
) ?? undefined
}
@animationend=${this._finishedAnimationEnded}
></ha-more-info-state-header>
<div class="controls">
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${
timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing
}
${
timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing
}
${
timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
size="s"
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing
}
</div>
</div>
`;
}
@@ -148,20 +195,34 @@ class MoreInfoTimer extends LitElement {
});
}
static styles = css`
ha-duration-input {
display: flex;
justify-content: center;
margin: var(--ha-space-4) 0 var(--ha-space-2);
}
.actions {
margin: var(--ha-space-2) 0;
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
`;
static styles = [
moreInfoControlStyle,
css`
ha-duration-input {
display: flex;
justify-content: center;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
ha-more-info-state-header.finished {
animation: timer-finished-pulse 0.5s ease-in-out 2;
}
@keyframes timer-finished-pulse {
50% {
color: var(--error-color);
}
}
@media (prefers-reduced-motion: reduce) {
ha-more-info-state-header.finished {
animation-duration: var(--ha-animation-duration-none);
}
}
`,
];
}
declare global {
@@ -0,0 +1,193 @@
import { consume } from "@lit/context";
import {
mdiFlagCheckered,
mdiPause,
mdiPlay,
mdiRestart,
mdiStop,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import type { HaControlButton } from "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-svg-icon";
import { apiContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import {
DEFAULT_TIMER_ACTIONS,
TIMER_ACTIONS,
type TimerActionsCardFeatureConfig,
type LovelaceCardFeatureContext,
} from "./types";
const supportsTimerActionsCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "timer";
};
export const supportsTimerActionsCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
return supportsTimerActionsCardFeatureFromState(stateObj);
};
interface TimerButton {
translationKey: string;
icon: string;
serviceName: string;
disabled: boolean;
}
export const TIMER_ACTIONS_BUTTON: Record<
string,
(stateObj: HassEntity) => TimerButton
> = {
// timer.start starts an idle timer, resumes a paused one, and restarts an
// active one with its configured duration.
start: (stateObj) =>
stateObj.state === "active"
? {
translationKey: "restart",
icon: mdiRestart,
serviceName: "start",
disabled: false,
}
: {
translationKey: "start",
icon: mdiPlay,
serviceName: "start",
disabled: false,
},
pause: (stateObj) => ({
translationKey: "pause",
icon: mdiPause,
serviceName: "pause",
disabled: stateObj.state !== "active",
}),
cancel: (stateObj) => ({
translationKey: "cancel",
icon: mdiStop,
serviceName: "cancel",
disabled: stateObj.state === "idle",
}),
finish: (stateObj) => ({
translationKey: "finish",
icon: mdiFlagCheckered,
serviceName: "finish",
disabled: stateObj.state === "idle",
}),
};
@customElement("hui-timer-actions-card-feature")
class HuiTimerActionsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state() private _config?: TimerActionsCardFeatureConfig;
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-timer-actions-card-feature-editor");
return document.createElement("hui-timer-actions-card-feature-editor");
}
static getStubConfig(): TimerActionsCardFeatureConfig {
return {
type: "timer-actions",
};
}
public setConfig(config: TimerActionsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.context ||
!this._stateObj ||
!supportsTimerActionsCardFeatureFromState(this._stateObj)
) {
return null;
}
const actions = this._config?.actions ?? DEFAULT_TIMER_ACTIONS;
return html`
<ha-control-button-group>
${actions
.filter((action) => TIMER_ACTIONS.includes(action))
.map((action) => {
const button = TIMER_ACTIONS_BUTTON[action](this._stateObj!);
return html`
<ha-control-button
.entry=${button}
.label=${this._localize(
// @ts-ignore
`ui.card.timer.actions.${button.translationKey}`
)}
@click=${this._onActionTap}
.disabled=${
button.disabled || this._stateObj?.state === UNAVAILABLE
}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
private _onActionTap(
ev: MouseEvent &
HASSDomCurrentTargetEvent<HaControlButton & { entry: TimerButton }>
): void {
ev.stopPropagation();
this._api.callService("timer", ev.currentTarget.entry.serviceName, {
entity_id: this._stateObj!.entity_id,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-actions-card-feature": HuiTimerActionsCardFeature;
}
}
@@ -0,0 +1,224 @@
import { consume } from "@lit/context";
import { mdiTimerOutline } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../common/datetime/duration_to_seconds";
import { formatNumericDuration } from "../../../common/datetime/format_duration";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import type { HaControlButton } from "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-svg-icon";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import { normalizeTimerDuration } from "../../../data/timer";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LovelaceCardFeatureContext,
TimerPresetsCardFeatureConfig,
} from "./types";
const supportsTimerPresetsCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "timer";
};
export const supportsTimerPresetsCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
return supportsTimerPresetsCardFeatureFromState(stateObj);
};
interface TimerPreset {
duration: number;
label: string;
}
// Presets with an unparseable or zero duration are dropped. Durations are
// normalized to seconds so what is shown and what is sent to timer.start
// always match, and numeric presets ("90") render the same as their string
// form ("0:01:30").
export const computeTimerPresets = (
presets: (string | number)[],
locale: FrontendLocaleData
): TimerPreset[] =>
presets.reduce<TimerPreset[]>((result, preset) => {
const durationData = createDurationData(preset);
const seconds = durationData ? durationDataToSeconds(durationData) : 0;
if (durationData && seconds > 0) {
const label = formatNumericDuration(
locale,
normalizeTimerDuration(durationData)
);
if (label) {
result.push({ duration: seconds, label });
}
}
return result;
}, []);
@customElement("hui-timer-presets-card-feature")
class HuiTimerPresetsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state() private _config?: TimerPresetsCardFeatureConfig;
private _presets = memoizeOne(
(presets: (string | number)[] | undefined, locale: FrontendLocaleData) =>
computeTimerPresets(presets ?? [], locale)
);
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-timer-presets-card-feature-editor");
return document.createElement("hui-timer-presets-card-feature-editor");
}
static getStubConfig(): TimerPresetsCardFeatureConfig {
return {
type: "timer-presets",
presets: ["0:01:00", "0:05:00", "0:10:00"],
};
}
public setConfig(config: TimerPresetsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render() {
if (
!this._config ||
!this.context ||
!this._stateObj ||
!this._locale ||
!supportsTimerPresetsCardFeatureFromState(this._stateObj)
) {
return nothing;
}
const presets = this._presets(this._config.presets, this._locale);
if (!presets.length) {
return nothing;
}
if (this._config.style === "dropdown") {
return html`
<ha-control-select-menu
show-arrow
.label=${this._localize("ui.card.timer.presets")}
.options=${presets.map((preset) => ({
value: String(preset.duration),
label: preset.label,
}))}
.disabled=${this._stateObj.state === UNAVAILABLE}
@wa-select=${this._onPresetSelect}
>
<ha-svg-icon slot="icon" .path=${mdiTimerOutline}></ha-svg-icon>
</ha-control-select-menu>
`;
}
return html`
<ha-control-button-group>
${presets.map(
(preset) => html`
<ha-control-button
.preset=${preset}
.label=${this._localize("ui.card.timer.start_preset", {
duration: preset.label,
})}
.disabled=${this._stateObj!.state === UNAVAILABLE}
@click=${this._onPresetTap}
>
${preset.label}
</ha-control-button>
`
)}
</ha-control-button-group>
`;
}
private _onPresetTap(
ev: MouseEvent &
HASSDomCurrentTargetEvent<HaControlButton & { preset: TimerPreset }>
): void {
ev.stopPropagation();
this._startPreset(ev.currentTarget.preset);
}
private _onPresetSelect(ev: CustomEvent<{ item?: { value: string } }>): void {
ev.stopPropagation();
const value = ev.detail.item?.value;
if (value === undefined || !this._config || !this._locale) {
return;
}
const presets = this._presets(this._config.presets, this._locale);
const preset = presets.find((p) => String(p.duration) === value);
if (preset) {
this._startPreset(preset);
}
}
private _startPreset(preset: TimerPreset): void {
this._api.callService("timer", "start", {
entity_id: this._stateObj!.entity_id,
duration: preset.duration,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-presets-card-feature": HuiTimerPresetsCardFeature;
}
}
@@ -40,6 +40,8 @@ import { supportsSelectOptionsCardFeature } from "./hui-select-options-card-feat
import { supportsTemperatureForecastCardFeature } from "./hui-temperature-forecast-card-feature";
import { supportsTargetHumidityCardFeature } from "./hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "./hui-target-temperature-card-feature";
import { supportsTimerActionsCardFeature } from "./hui-timer-actions-card-feature";
import { supportsTimerPresetsCardFeature } from "./hui-timer-presets-card-feature";
import { supportsToggleCardFeature } from "./hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "./hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "./hui-update-actions-card-feature";
@@ -104,6 +106,8 @@ export const UI_FEATURE_TYPES = [
"trend-graph",
"target-humidity",
"target-temperature",
"timer-actions",
"timer-presets",
"toggle",
"update-actions",
"vacuum-commands",
@@ -160,6 +164,8 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
"timer-actions": supportsTimerActionsCardFeature,
"timer-presets": supportsTimerPresetsCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
@@ -195,6 +195,27 @@ export interface ToggleCardFeatureConfig {
type: "toggle";
}
export const TIMER_ACTIONS = ["start", "pause", "cancel", "finish"] as const;
export type TimerActions = (typeof TIMER_ACTIONS)[number];
export const DEFAULT_TIMER_ACTIONS: TimerActions[] = [
"start",
"pause",
"cancel",
];
export interface TimerActionsCardFeatureConfig {
type: "timer-actions";
actions?: TimerActions[];
}
export interface TimerPresetsCardFeatureConfig {
type: "timer-presets";
style?: "buttons" | "dropdown";
presets?: (string | number)[];
}
export interface WaterHeaterOperationModesCardFeatureConfig {
type: "water-heater-operation-modes";
style?: "dropdown" | "icons";
@@ -359,6 +380,8 @@ export type LovelaceCardFeatureConfig =
| TrendGraphCardFeatureConfig
| TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig
| TimerActionsCardFeatureConfig
| TimerPresetsCardFeatureConfig
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
@@ -83,6 +83,7 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
],
alarm_control_panel: [TILE_VARIANT, ["alarm-modes"]],
counter: [TILE_VARIANT, ["counter-actions"]],
timer: [TILE_VARIANT, ["timer-actions"]],
input_select: SELECT_VARIANTS,
select: SELECT_VARIANTS,
input_number: NUMERIC_INPUT_VARIANTS,
+48 -1
View File
@@ -1,4 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -18,6 +19,7 @@ import "../../../components/tile/ha-tile-container";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import { timerJustFinished } from "../../../data/timer";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
@@ -91,6 +93,31 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@state() private _featureContext: LovelaceCardFeatureContext = {};
@state() private _timerFinished = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
!changedProps.has("hass") ||
!this._config?.entity ||
computeDomain(this._config.entity) !== "timer"
) {
return;
}
const stateObj = this.hass?.states[this._config.entity];
const oldStateObj = (changedProps.get("hass") as HomeAssistant | undefined)
?.states[this._config.entity];
if (stateObj && timerJustFinished(oldStateObj, stateObj)) {
this._timerFinished = true;
}
}
private _timerFinishedAnimationEnded(ev: AnimationEvent): void {
if (ev.animationName === "timer-finished-pulse") {
this._timerFinished = false;
}
}
public setConfig(config: TileCardConfig): void {
if (!config.entity) {
throw new Error("Specify an entity");
@@ -312,7 +339,11 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.imageUrl=${imageUrl}
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
class=${classMap({ image: hasImage })}
class=${classMap({
image: hasImage,
"timer-finished": this._timerFinished,
})}
@animationend=${this._timerFinishedAnimationEnded}
>
${
hasImage
@@ -394,6 +425,22 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
animation: pulse 1s infinite;
}
ha-tile-icon.timer-finished {
animation: timer-finished-pulse 0.5s ease-in-out 2;
}
@keyframes timer-finished-pulse {
50% {
--tile-icon-color: var(--error-color);
}
}
@media (prefers-reduced-motion: reduce) {
ha-tile-icon.timer-finished {
animation-duration: var(--ha-animation-duration-none);
}
}
/* Make sure we display the whole image */
ha-tile-icon.image[data-domain="update"] {
--tile-icon-border-radius: var(--ha-border-radius-square);
@@ -35,6 +35,8 @@ import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
import "../card-features/hui-target-humidity-card-feature";
import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-timer-actions-card-feature";
import "../card-features/hui-timer-presets-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature";
@@ -98,6 +100,8 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"target-humidity",
"target-temperature",
"temperature-forecast",
"timer-actions",
"timer-presets",
"toggle",
"update-actions",
"vacuum-commands",
@@ -64,6 +64,8 @@ import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsTimerActionsCardFeature } from "../../card-features/hui-timer-actions-card-feature";
import { supportsTimerPresetsCardFeature } from "../../card-features/hui-timer-presets-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "../../card-features/hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
@@ -130,6 +132,8 @@ const UI_FEATURE_TYPES = [
"target-humidity",
"target-temperature",
"temperature-forecast",
"timer-actions",
"timer-presets",
"toggle",
"update-actions",
"vacuum-commands",
@@ -169,6 +173,8 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"media-player-volume-slider",
"numeric-input",
"select-options",
"timer-actions",
"timer-presets",
"trend-graph",
"update-actions",
"vacuum-commands",
@@ -224,6 +230,8 @@ const SUPPORTS_FEATURE_TYPES: Record<
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
"timer-actions": supportsTimerActionsCardFeature,
"timer-presets": supportsTimerPresetsCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
@@ -0,0 +1,91 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import type {
TimerActionsCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import {
DEFAULT_TIMER_ACTIONS,
TIMER_ACTIONS,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-timer-actions-card-feature-editor")
export class HuiTimerActionsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TimerActionsCardFeatureConfig;
public setConfig(config: TimerActionsCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne((localize: LocalizeFunc) =>
customizableListSchema({
field: "actions",
options: TIMER_ACTIONS.map((action) => ({
value: action,
label: localize(
`ui.panel.lovelace.editor.features.types.timer-actions.actions_list.${action}`
),
})),
})
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data = customizableListData(this._config, "actions");
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
const config = processCustomizableListValue<TimerActionsCardFeatureConfig>(
ev.detail.value,
"actions",
DEFAULT_TIMER_ACTIONS
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.timer-actions.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-actions-card-feature-editor": HuiTimerActionsCardFeatureEditor;
}
}
@@ -0,0 +1,235 @@
import { mdiDelete, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { uid } from "../../../../common/util/uid";
import "../../../../components/ha-button";
import "../../../../components/ha-duration-input";
import type { HaDurationData } from "../../../../components/ha-duration-input";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import {
durationDataToTimerString,
normalizeTimerDuration,
} from "../../../../data/timer";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureContext,
TimerPresetsCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
const NEW_PRESET = "0:05:00";
@customElement("hui-timer-presets-card-feature-editor")
export class HuiTimerPresetsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TimerPresetsCardFeatureConfig;
// Stable key per row so rows keep their DOM (and input focus) while other
// rows are added, removed, or dragged. Presets are plain strings, so keys
// are tracked in a parallel array, mirroring ha-input-multi.
@state() private _keys: string[] = [];
public setConfig(config: TimerPresetsCardFeatureConfig): void {
this._config = config;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_config")) {
const length = this._config?.presets?.length ?? 0;
if (this._keys.length !== length) {
this._keys = Array.from(
{ length },
(_, index) => this._keys[index] ?? uid()
);
}
}
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "style",
selector: {
select: {
multiple: false,
mode: "list",
options: ["buttons", "dropdown"].map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.features.types.timer-presets.style_list.${mode}`
),
})),
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const presets = this._config.presets ?? [];
const data: TimerPresetsCardFeatureConfig = {
style: "buttons",
...this._config,
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._formChanged}
></ha-form>
<ha-sortable
handle-selector=".handle"
draggable-selector=".preset"
@item-moved=${this._presetMoved}
>
<div class="presets">
${repeat(
presets,
(_preset, index) => this._keys[index],
(preset, index) => html`
<div class="preset">
<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<ha-duration-input
.data=${this._presetDurationData(preset)}
.index=${index}
required
@value-changed=${this._presetChanged}
></ha-duration-input>
<ha-icon-button
.path=${mdiDelete}
.index=${index}
.label=${this.hass!.localize("ui.common.remove")}
@click=${this._removePreset}
></ha-icon-button>
</div>
`
)}
</div>
</ha-sortable>
<ha-button appearance="plain" @click=${this._addPreset}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.common.add")}
</ha-button>
`;
}
private _formChanged(ev: CustomEvent): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.timer-presets.${schema.name}`
);
// Normalize before binding so numeric presets ({seconds: 3600}) show as
// 1:00:00 instead of overflowing the seconds field, where the input's
// carry-over logic would corrupt the value on the next edit.
private _presetDurationData(
preset: string | number
): HaDurationData | undefined {
const durationData = createDurationData(preset);
return durationData ? normalizeTimerDuration(durationData) : undefined;
}
private _presetChanged(ev: CustomEvent): void {
ev.stopPropagation();
const index = (ev.currentTarget as any).index as number;
const value = ev.detail.value as HaDurationData | undefined;
const presets = [...(this._config!.presets ?? [])];
presets[index] = durationDataToTimerString(value ?? {});
this._updatePresets(presets);
}
private _presetMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const presets = [...(this._config!.presets ?? [])];
const [moved] = presets.splice(oldIndex, 1);
presets.splice(newIndex, 0, moved);
// Move the row's key with it so its DOM identity is preserved.
const keys = [...this._keys];
const [movedKey] = keys.splice(oldIndex, 1);
keys.splice(newIndex, 0, movedKey);
this._keys = keys;
this._updatePresets(presets);
}
private _removePreset(ev: Event): void {
const index = (ev.currentTarget as any).index as number;
this._keys = this._keys.filter((_, i) => i !== index);
const presets = [...(this._config!.presets ?? [])];
presets.splice(index, 1);
this._updatePresets(presets);
}
private _addPreset(): void {
this._keys = [...this._keys, uid()];
this._updatePresets([...(this._config!.presets ?? []), NEW_PRESET]);
}
private _updatePresets(presets: (string | number)[]): void {
fireEvent(this, "config-changed", {
config: { ...this._config!, presets },
});
}
static styles = css`
ha-form {
display: block;
margin-bottom: var(--ha-space-4);
}
.preset {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
}
.preset ha-duration-input {
flex: 1;
}
.handle {
cursor: grab;
padding: var(--ha-space-2);
margin: calc(var(--ha-space-2) * -1);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-timer-presets-card-feature-editor": HuiTimerPresetsCardFeatureEditor;
}
}
+16 -48
View File
@@ -1,8 +1,9 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDisplayTimer, timerTimeRemaining } from "../data/timer";
import { customElement, property } from "lit/decorators";
import { TimerRemainingTimeController } from "../common/controllers/timer-remaining-time-controller";
import { computeDisplayTimer } from "../data/timer";
import type { HomeAssistant } from "../types";
@customElement("ha-timer-remaining-time")
@@ -11,60 +12,27 @@ class HaTimerRemainingTime extends ReactiveElement {
@property({ attribute: false }) public stateObj!: HassEntity;
@state() private timeRemaining?: number;
private _updateRemaining: any;
private _remainingTime = new TimerRemainingTimeController(this);
protected createRenderRoot() {
return this;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("stateObj")) {
this._remainingTime.setStateObj(this.stateObj);
}
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
this.innerHTML =
computeDisplayTimer(this.hass, this.stateObj, this.timeRemaining) ?? "-";
}
connectedCallback() {
super.connectedCallback();
if (this.stateObj) {
this._startInterval(this.stateObj);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._clearInterval();
}
protected willUpdate(changedProp: PropertyValues<this>): void {
super.willUpdate(changedProp);
if (changedProp.has("stateObj")) {
this._startInterval(this.stateObj);
}
}
private _clearInterval() {
if (this._updateRemaining) {
clearInterval(this._updateRemaining);
this._updateRemaining = null;
}
}
private _startInterval(stateObj: HassEntity) {
this._clearInterval();
this._calculateRemaining(stateObj);
if (stateObj.state === "active") {
this._updateRemaining = setInterval(
() => this._calculateRemaining(this.stateObj),
1000
);
}
}
private _calculateRemaining(stateObj: HassEntity) {
this.timeRemaining = timerTimeRemaining(stateObj);
computeDisplayTimer(
this.hass.formatEntityState,
this.stateObj,
this._remainingTime.timeRemaining
) ?? "-";
}
}
+23 -1
View File
@@ -325,8 +325,11 @@
"pause": "Pause",
"cancel": "Cancel",
"finish": "Finish",
"restart": "Restart",
"set": "Set"
}
},
"presets": "Presets",
"start_preset": "Start {duration}"
},
"vacuum": {
"actions": {
@@ -10863,6 +10866,25 @@
"reset": "Reset"
}
},
"timer-actions": {
"label": "Timer actions",
"customize": "[%key:ui::panel::lovelace::editor::features::types::counter-actions::customize%]",
"actions": "[%key:ui::panel::lovelace::editor::features::types::counter-actions::actions%]",
"actions_list": {
"start": "[%key:ui::card::timer::actions::start%]",
"pause": "[%key:ui::card::timer::actions::pause%]",
"cancel": "[%key:ui::card::timer::actions::cancel%]",
"finish": "[%key:ui::card::timer::actions::finish%]"
}
},
"timer-presets": {
"label": "Timer presets",
"style": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style%]",
"style_list": {
"buttons": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons%]",
"dropdown": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style_list::dropdown%]"
}
},
"fan-preset-modes": {
"label": "Fan preset modes",
"style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]",
+165 -2
View File
@@ -1,6 +1,19 @@
import { assert, describe, it } from "vitest";
import { assert, describe, expect, it } from "vitest";
import { timerDurationData } from "../../src/data/timer";
import { createDurationData } from "../../src/common/datetime/create_duration_data";
import {
computeDisplayTimer,
durationDataToTimerString,
normalizeTimerDuration,
timerDurationData,
timerJustFinished,
} from "../../src/data/timer";
const timerState = (state: string, lastTransition?: string) =>
({
state,
attributes: { last_transition: lastTransition },
}) as any;
describe("timerDurationData", () => {
it("derives the input prefill from the configured duration", () => {
@@ -43,3 +56,153 @@ describe("timerDurationData", () => {
);
});
});
describe("durationDataToTimerString", () => {
it("serializes with zero-padded minutes and seconds", () => {
assert.strictEqual(
durationDataToTimerString({ hours: 1, minutes: 5, seconds: 7 }),
"1:05:07"
);
});
it("treats missing fields as zero", () => {
assert.strictEqual(durationDataToTimerString({ minutes: 30 }), "0:30:00");
assert.strictEqual(durationDataToTimerString({}), "0:00:00");
});
it("folds days into hours", () => {
assert.strictEqual(
durationDataToTimerString({ days: 1, hours: 2 }),
"26:00:00"
);
});
it("round-trips through createDurationData", () => {
const data = createDurationData("2:15:30")!;
assert.strictEqual(durationDataToTimerString(data), "2:15:30");
});
it("normalizes out-of-range fields", () => {
assert.strictEqual(durationDataToTimerString({ seconds: 3600 }), "1:00:00");
assert.strictEqual(durationDataToTimerString({ minutes: 90 }), "1:30:00");
});
it("floors fractional seconds instead of serializing decimals", () => {
assert.strictEqual(durationDataToTimerString({ seconds: 1.5 }), "0:00:01");
assert.strictEqual(
durationDataToTimerString({ seconds: 1, milliseconds: 500 }),
"0:00:01"
);
});
});
describe("normalizeTimerDuration", () => {
it("decomposes overflowing fields into hours/minutes/seconds", () => {
assert.deepEqual(normalizeTimerDuration({ seconds: 3600 }), {
hours: 1,
minutes: 0,
seconds: 0,
});
assert.deepEqual(normalizeTimerDuration({ days: 1, hours: 2 }), {
hours: 26,
minutes: 0,
seconds: 0,
});
});
it("drops fractional seconds", () => {
assert.deepEqual(normalizeTimerDuration({ seconds: 90.9 }), {
hours: 0,
minutes: 1,
seconds: 30,
});
});
});
describe("timerJustFinished", () => {
it("detects a timer running out or being finished", () => {
expect(
timerJustFinished(timerState("active"), timerState("idle", "finished"))
).toBe(true);
expect(
timerJustFinished(timerState("paused"), timerState("idle", "finished"))
).toBe(true);
});
it("does not match a cancelled timer", () => {
expect(
timerJustFinished(timerState("active"), timerState("idle", "cancelled"))
).toBe(false);
});
it("does not match without a state transition", () => {
expect(
timerJustFinished(
timerState("idle", "finished"),
timerState("idle", "finished")
)
).toBe(false);
expect(timerJustFinished(undefined, timerState("idle", "finished"))).toBe(
false
);
});
it("does not match on older cores without last_transition", () => {
expect(timerJustFinished(timerState("active"), timerState("idle"))).toBe(
false
);
});
});
describe("computeDisplayTimer", () => {
const formatEntityState = (stateObj: any) =>
stateObj.state === "idle"
? "Idle"
: stateObj.state === "paused"
? "Paused"
: "Active";
it("shows the formatted state when idle", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "idle", attributes: {} } as any,
undefined
),
"Idle"
);
});
it("shows the formatted state when the remaining time is zero", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "active", attributes: {} } as any,
0
),
"Active"
);
});
it("shows the remaining time when active", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "active", attributes: {} } as any,
90
),
"1:30"
);
});
it("appends the formatted state when paused", () => {
assert.strictEqual(
computeDisplayTimer(
formatEntityState,
{ state: "paused", attributes: {} } as any,
90
),
"1:30 (Paused)"
);
});
});
@@ -0,0 +1,89 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import {
supportsTimerActionsCardFeature,
TIMER_ACTIONS_BUTTON,
} from "../../../../src/panels/lovelace/card-features/hui-timer-actions-card-feature";
import type { HomeAssistant } from "../../../../src/types";
const entity = (entityId: string, state = "idle"): HassEntity =>
({
entity_id: entityId,
state,
attributes: {},
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
({
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
}) as unknown as HomeAssistant;
describe("supportsTimerActionsCardFeature", () => {
it("supports a timer entity", () => {
const stateObj = entity("timer.test");
const hass = hassWith(stateObj);
expect(
supportsTimerActionsCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(true);
});
it("does not support other domains", () => {
const stateObj = entity("switch.test");
const hass = hassWith(stateObj);
expect(
supportsTimerActionsCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support a context with no entity_id", () => {
const hass = hassWith();
expect(supportsTimerActionsCardFeature(hass, {})).toBe(false);
});
});
describe("TIMER_ACTIONS_BUTTON", () => {
it("shows start when the timer is idle or paused", () => {
for (const state of ["idle", "paused"]) {
const button = TIMER_ACTIONS_BUTTON.start(entity("timer.test", state));
expect(button.translationKey).toBe("start");
expect(button.serviceName).toBe("start");
expect(button.disabled).toBe(false);
}
});
it("shows restart when the timer is active", () => {
const button = TIMER_ACTIONS_BUTTON.start(entity("timer.test", "active"));
expect(button.translationKey).toBe("restart");
expect(button.serviceName).toBe("start");
expect(button.disabled).toBe(false);
});
it("only enables pause when the timer is active", () => {
expect(
TIMER_ACTIONS_BUTTON.pause(entity("timer.test", "active")).disabled
).toBe(false);
expect(
TIMER_ACTIONS_BUTTON.pause(entity("timer.test", "paused")).disabled
).toBe(true);
expect(
TIMER_ACTIONS_BUTTON.pause(entity("timer.test", "idle")).disabled
).toBe(true);
});
it("disables cancel and finish when the timer is idle", () => {
for (const action of ["cancel", "finish"]) {
expect(
TIMER_ACTIONS_BUTTON[action](entity("timer.test", "idle")).disabled
).toBe(true);
expect(
TIMER_ACTIONS_BUTTON[action](entity("timer.test", "active")).disabled
).toBe(false);
expect(
TIMER_ACTIONS_BUTTON[action](entity("timer.test", "paused")).disabled
).toBe(false);
}
});
});
@@ -0,0 +1,86 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
computeTimerPresets,
supportsTimerPresetsCardFeature,
} from "../../../../src/panels/lovelace/card-features/hui-timer-presets-card-feature";
import type { HomeAssistant } from "../../../../src/types";
const entity = (entityId: string): HassEntity =>
({
entity_id: entityId,
state: "idle",
attributes: {},
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
({
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
}) as unknown as HomeAssistant;
const locale = { language: "en" } as FrontendLocaleData;
describe("supportsTimerPresetsCardFeature", () => {
it("supports a timer entity", () => {
const stateObj = entity("timer.test");
const hass = hassWith(stateObj);
expect(
supportsTimerPresetsCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(true);
});
it("does not support other domains", () => {
const stateObj = entity("switch.test");
const hass = hassWith(stateObj);
expect(
supportsTimerPresetsCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support a context with no entity_id", () => {
const hass = hassWith();
expect(supportsTimerPresetsCardFeature(hass, {})).toBe(false);
});
});
describe("computeTimerPresets", () => {
it("formats string presets", () => {
expect(computeTimerPresets(["0:05:00", "1:00:00"], locale)).toEqual([
{ duration: 300, label: "5:00" },
{ duration: 3600, label: "1:00:00" },
]);
});
it("labels numeric presets the same as their string form", () => {
expect(computeTimerPresets([90, "0:01:30"], locale)).toEqual([
{ duration: 90, label: "1:30" },
{ duration: 90, label: "1:30" },
]);
expect(computeTimerPresets([3600], locale)).toEqual([
{ duration: 3600, label: "1:00:00" },
]);
expect(computeTimerPresets([45, "0:00:45"], locale)).toEqual([
{ duration: 45, label: "45 seconds" },
{ duration: 45, label: "45 seconds" },
]);
});
it("drops zero and unparseable presets", () => {
expect(
computeTimerPresets(["0:00:00", 0, "1:2:3:4", "0:10:00"], locale)
).toEqual([{ duration: 600, label: "10:00" }]);
});
it("sends the normalized duration for lenient parsed presets", () => {
// createDurationData coerces invalid segments to 0 like the automation
// editor does. The normalized seconds must be what is sent to timer.start,
// never the raw malformed string.
expect(computeTimerPresets(["1:nope:00"], locale)).toEqual([
{ duration: 3600, label: "1:00:00" },
]);
});
});