mirror of
https://github.com/GeorgeSG/lovelace-time-picker-card.git
synced 2026-11-02 03:08:45 +00:00
Compare commits
3
Commits
e7aade9647
...
44cb92bba4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44cb92bba4 | ||
|
|
326c195dfd | ||
|
|
81ca4cfb53 |
@@ -11,7 +11,7 @@
|
||||
|
||||
This is a Time Picker Card for [Home Assistant](https://www.home-assistant.io/)'s [Lovelace UI](https://www.home-assistant.io/lovelace).
|
||||
|
||||
Requires an [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) that has time (`has_time: true`).
|
||||
Supports a [Time](https://www.home-assistant.io/integrations/time/) entity or an [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity that has time (`has_time: true`).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -66,6 +66,16 @@ hide:
|
||||
name: true
|
||||
```
|
||||
|
||||
### `time` entity
|
||||
|
||||
```yaml
|
||||
type: custom:time-picker-card
|
||||
entity: time.alarm_time
|
||||
minute_step: 5
|
||||
```
|
||||
|
||||
The card reads the time entity's `HH:MM:SS` state and updates it using `time.set_value`.
|
||||
|
||||
### Custom config - hidden card name, 12 hour mode with a "single" hour mode picker
|
||||
|
||||

|
||||
@@ -107,22 +117,22 @@ layout:
|
||||
|
||||
## Options
|
||||
|
||||
| Name | Type | Requirement | Description | Default |
|
||||
| ----------------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| type | string | **Required** | `custom:time-picker-card` | |
|
||||
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
|
||||
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
|
||||
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
|
||||
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
|
||||
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
|
||||
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
|
||||
| second_step | number | **Optional** | Second change when clicking arrows (only with `hide.seconds: false`) | `5` |
|
||||
| delay | number | **Optional** | Delay in ms before updating entity | `0` |
|
||||
| layout | object | **Optional** | Card Layout configuration | `none` |
|
||||
| hide | object | **Optional** | Hide object | `none` |
|
||||
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| Name | Type | Requirement | Description | Default |
|
||||
| ----------------- | ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| type | string | **Required** | `custom:time-picker-card` | |
|
||||
| entity | string | **Required** | [Time](https://www.home-assistant.io/integrations/time/) entity or [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
|
||||
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
|
||||
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
|
||||
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
|
||||
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
|
||||
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
|
||||
| second_step | number | **Optional** | Second change when clicking arrows (only with `hide.seconds: false`) | `5` |
|
||||
| delay | number | **Optional** | Delay in ms before updating entity | `0` |
|
||||
| layout | object | **Optional** | Card Layout configuration | `none` |
|
||||
| hide | object | **Optional** | Hide object | `none` |
|
||||
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
|
||||
### Layout Object
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CARD_VERSION = pkg.version;
|
||||
export const CARD_SIZE = 3;
|
||||
|
||||
export const ENTITY_DOMAIN = 'input_datetime';
|
||||
export const TIME_ENTITY_DOMAIN = 'time';
|
||||
|
||||
// Config defaults
|
||||
export const DEFAULT_HOUR_STEP = 1;
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { TimePickerCardConfig } from './types';
|
||||
|
||||
const NAME_TO_LABEL_MAP: Record<string, string> = {
|
||||
entity: 'input_datetime entity id',
|
||||
entity: 'time or input_datetime entity id',
|
||||
name: 'Name',
|
||||
hour_step: 'Hour step',
|
||||
minute_step: 'Minute step',
|
||||
@@ -28,7 +28,7 @@ const nameSchema = (hass: HomeAssistant): FormSchemaEntry =>
|
||||
: { name: 'name', selector: { text: {} } };
|
||||
|
||||
const schema = (hass: HomeAssistant): FormSchemaEntry[] => [
|
||||
{ name: 'entity', selector: { entity: { domain: 'input_datetime' } } },
|
||||
{ name: 'entity', selector: { entity: { domain: ['input_datetime', 'time'] } } },
|
||||
nameSchema(hass),
|
||||
{
|
||||
type: 'grid',
|
||||
|
||||
+41
-11
@@ -24,6 +24,7 @@ import {
|
||||
DEFAULT_LAYOUT_NAME,
|
||||
DEFAULT_MINUTE_STEP,
|
||||
ENTITY_DOMAIN,
|
||||
TIME_ENTITY_DOMAIN,
|
||||
} from './const';
|
||||
import './editor';
|
||||
import { Hour } from './models/hour';
|
||||
@@ -43,7 +44,7 @@ window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: 'time-picker-card',
|
||||
name: 'Time Picker Card',
|
||||
description: 'A Time Picker card for setting the time value of Input Datetime entities.',
|
||||
description: 'A Time Picker card for input_datetime and time entities.',
|
||||
});
|
||||
|
||||
@customElement('time-picker-card')
|
||||
@@ -170,18 +171,33 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
return Partial.error('Entity not found', this.config);
|
||||
}
|
||||
|
||||
if (computeDomain(this.entity.entity_id) !== ENTITY_DOMAIN) {
|
||||
return Partial.error(`You must set an ${ENTITY_DOMAIN} entity`, this.config);
|
||||
const domain = computeDomain(this.entity.entity_id);
|
||||
if (domain !== ENTITY_DOMAIN && domain !== TIME_ENTITY_DOMAIN) {
|
||||
return Partial.error(
|
||||
`You must set an ${ENTITY_DOMAIN} or ${TIME_ENTITY_DOMAIN} entity`,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.entity.attributes.has_time) {
|
||||
if (domain === ENTITY_DOMAIN && !this.entity.attributes.has_time) {
|
||||
return Partial.error(
|
||||
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
|
||||
const { hour, minute, second } = this.entity!.attributes;
|
||||
let hour: number;
|
||||
let minute: number;
|
||||
let second: number;
|
||||
if (domain === TIME_ENTITY_DOMAIN) {
|
||||
const match = /^(\d{2}):(\d{2}):(\d{2})$/.exec(this.entity.state);
|
||||
if (!match || Number(match[1]) > 23 || Number(match[2]) > 59 || Number(match[3]) > 59) {
|
||||
return Partial.error('The time entity has no valid HH:MM:SS value', this.config);
|
||||
}
|
||||
[hour, minute, second] = match.slice(1).map(Number);
|
||||
} else {
|
||||
({ hour, minute, second } = this.entity.attributes);
|
||||
}
|
||||
const hourInstance = new Hour(hour, this.config.hour_step, this.config.hour_mode);
|
||||
const minuteInstance = new Minute(minute, this.config.minute_step);
|
||||
const secondInstance = new Second(second, this.config.second_step);
|
||||
@@ -286,13 +302,25 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private callHassService(): Promise<void> {
|
||||
if (!this.hass) {
|
||||
throw new Error('Unable to update datetime');
|
||||
throw new Error('Unable to update time');
|
||||
}
|
||||
|
||||
return this.hass.callService(ENTITY_DOMAIN, 'set_datetime', {
|
||||
entity_id: this.entity!.entity_id,
|
||||
time: this.time.value,
|
||||
});
|
||||
const domain = computeDomain(this.entity!.entity_id);
|
||||
const time =
|
||||
domain === TIME_ENTITY_DOMAIN
|
||||
? [this.time.hour.value, this.time.minute.value, this.time.second.value]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.join(':')
|
||||
: this.time.value;
|
||||
|
||||
return this.hass.callService(
|
||||
domain,
|
||||
domain === TIME_ENTITY_DOMAIN ? 'set_value' : 'set_datetime',
|
||||
{
|
||||
entity_id: this.entity!.entity_id,
|
||||
time,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
@@ -390,7 +418,9 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
_: HomeAssistant,
|
||||
entities: Array<string>,
|
||||
): Omit<TimePickerCardConfig, 'type'> {
|
||||
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
|
||||
const datetimeEntity = entities.find((entityId) =>
|
||||
[ENTITY_DOMAIN, TIME_ENTITY_DOMAIN].includes(computeDomain(entityId)),
|
||||
);
|
||||
|
||||
return {
|
||||
entity: datetimeEntity || '',
|
||||
|
||||
@@ -17,6 +17,7 @@ class ErrorCardStub extends HTMLElement {
|
||||
customElements.define('hui-error-card', ErrorCardStub);
|
||||
|
||||
const ENTITY_ID = 'input_datetime.alarm';
|
||||
const TIME_ENTITY_ID = 'time.alarm_time';
|
||||
|
||||
/** YAML is untyped at runtime, so setConfig has to cope with shapes the type forbids. */
|
||||
const invalidConfig = (config: Record<string, unknown>): TimePickerCardConfig =>
|
||||
@@ -31,6 +32,13 @@ const alarm = (hour: number, minute = 30, second = 0): HassEntity => ({
|
||||
context: { id: '', user_id: null, parent_id: null },
|
||||
});
|
||||
|
||||
const timeEntity = (state = '07:30:00'): HassEntity => ({
|
||||
...alarm(7),
|
||||
entity_id: TIME_ENTITY_ID,
|
||||
state,
|
||||
attributes: { friendly_name: 'Alarm time' },
|
||||
});
|
||||
|
||||
const createHass = (entity: HassEntity = alarm(7)): HomeAssistant =>
|
||||
({
|
||||
states: { [entity.entity_id]: entity },
|
||||
@@ -116,6 +124,28 @@ describe('time-picker-card', () => {
|
||||
expect(inputValues(card)).toEqual(['09', '45']);
|
||||
});
|
||||
|
||||
it('reads a time entity from its state and follows state updates', async () => {
|
||||
card = await renderCard({ entity: TIME_ENTITY_ID }, createHass(timeEntity()));
|
||||
expect(inputValues(card)).toEqual(['07', '30']);
|
||||
|
||||
card.hass = createHass(timeEntity('09:45:06'));
|
||||
await settle(card);
|
||||
expect(inputValues(card)).toEqual(['09', '45']);
|
||||
});
|
||||
|
||||
it('rejects an unavailable time entity instead of rendering invalid numbers', async () => {
|
||||
card = await renderCard({ entity: TIME_ENTITY_ID }, createHass(timeEntity('unavailable')));
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
expect(error.config?.error).toContain('HH:MM:SS');
|
||||
});
|
||||
|
||||
it('rejects entities from other domains', async () => {
|
||||
const entity = { ...alarm(7), entity_id: 'sensor.alarm' };
|
||||
card = await renderCard({ entity: entity.entity_id }, createHass(entity));
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
expect(error.config?.error).toContain('input_datetime or time');
|
||||
});
|
||||
|
||||
it('renders an error card when the entity is missing', async () => {
|
||||
card = await renderCard({ entity: 'input_datetime.missing' });
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
@@ -143,6 +173,18 @@ describe('time-picker-card', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('calls time.set_value with a zero-padded time for time entities', async () => {
|
||||
const hass = createHass(timeEntity());
|
||||
card = await renderCard({ entity: TIME_ENTITY_ID }, hass);
|
||||
const hourUnit = card.shadowRoot!.querySelector('time-unit')!;
|
||||
hourUnit.shadowRoot!.querySelector<HTMLElement>('.time-picker-icon')!.click();
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith('time', 'set_value', {
|
||||
entity_id: TIME_ENTITY_ID,
|
||||
time: '08:30:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('debounces the service call when a delay is configured', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -168,4 +210,10 @@ describe('time-picker-card', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('offers time entities in the generated configuration', () => {
|
||||
expect(
|
||||
TimePickerCard.getStubConfig(createHass(), ['sensor.unknown', TIME_ENTITY_ID]).entity,
|
||||
).toBe(TIME_ENTITY_ID);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user