mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-28 01:47:26 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1171afd0b | ||
|
|
b045bd903a | ||
|
|
cac23916b4 | ||
|
|
8562e068ea | ||
|
|
0aaf64ae07 | ||
|
|
a9239b123e |
@@ -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
@@ -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.
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
|
||||
"siren",
|
||||
"script",
|
||||
"switch",
|
||||
"timer",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) ?? "-";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user