Merge pull request #68 from AcMetelka/master

Feature to accept Time HA entity.
This commit is contained in:
2026-10-05 09:01:19 +03:00
committed by GitHub
5 changed files with 119 additions and 30 deletions
+27 -17
View File
@@ -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). 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 ## Installation
@@ -66,6 +66,16 @@ hide:
name: true 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 ### Custom config - hidden card name, 12 hour mode with a "single" hour mode picker
![Default theme with single hour mode](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/single_hour_mode.png) ![Default theme with single hour mode](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/single_hour_mode.png)
@@ -107,22 +117,22 @@ layout:
## Options ## Options
| Name | Type | Requirement | Description | Default | | Name | Type | Requirement | Description | Default |
| ----------------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------- | ------------- | | ----------------- | ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| type | string | **Required** | `custom:time-picker-card` | | | 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` | | | 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 | | 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` | | 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_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` | | hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` | | 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` | | 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` | | delay | number | **Optional** | Delay in ms before updating entity | `0` |
| layout | object | **Optional** | Card Layout configuration | `none` | | layout | object | **Optional** | Card Layout configuration | `none` |
| hide | object | **Optional** | Hide object | `none` | | hide | object | **Optional** | Hide object | `none` |
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | | 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` | | 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` | | hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
### Layout Object ### Layout Object
+1
View File
@@ -5,6 +5,7 @@ export const CARD_VERSION = pkg.version;
export const CARD_SIZE = 3; export const CARD_SIZE = 3;
export const ENTITY_DOMAIN = 'input_datetime'; export const ENTITY_DOMAIN = 'input_datetime';
export const TIME_ENTITY_DOMAIN = 'time';
// Config defaults // Config defaults
export const DEFAULT_HOUR_STEP = 1; export const DEFAULT_HOUR_STEP = 1;
+2 -2
View File
@@ -5,7 +5,7 @@ import { customElement, property } from 'lit/decorators.js';
import { TimePickerCardConfig } from './types'; import { TimePickerCardConfig } from './types';
const NAME_TO_LABEL_MAP: Record<string, string> = { const NAME_TO_LABEL_MAP: Record<string, string> = {
entity: 'input_datetime entity id', entity: 'time or input_datetime entity id',
name: 'Name', name: 'Name',
hour_step: 'Hour step', hour_step: 'Hour step',
minute_step: 'Minute step', minute_step: 'Minute step',
@@ -28,7 +28,7 @@ const nameSchema = (hass: HomeAssistant): FormSchemaEntry =>
: { name: 'name', selector: { text: {} } }; : { name: 'name', selector: { text: {} } };
const schema = (hass: HomeAssistant): FormSchemaEntry[] => [ const schema = (hass: HomeAssistant): FormSchemaEntry[] => [
{ name: 'entity', selector: { entity: { domain: 'input_datetime' } } }, { name: 'entity', selector: { entity: { domain: ['input_datetime', 'time'] } } },
nameSchema(hass), nameSchema(hass),
{ {
type: 'grid', type: 'grid',
+41 -11
View File
@@ -24,6 +24,7 @@ import {
DEFAULT_LAYOUT_NAME, DEFAULT_LAYOUT_NAME,
DEFAULT_MINUTE_STEP, DEFAULT_MINUTE_STEP,
ENTITY_DOMAIN, ENTITY_DOMAIN,
TIME_ENTITY_DOMAIN,
} from './const'; } from './const';
import './editor'; import './editor';
import { Hour } from './models/hour'; import { Hour } from './models/hour';
@@ -43,7 +44,7 @@ window.customCards = window.customCards || [];
window.customCards.push({ window.customCards.push({
type: 'time-picker-card', type: 'time-picker-card',
name: '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') @customElement('time-picker-card')
@@ -170,18 +171,33 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
return Partial.error('Entity not found', this.config); return Partial.error('Entity not found', this.config);
} }
if (computeDomain(this.entity.entity_id) !== ENTITY_DOMAIN) { const domain = computeDomain(this.entity.entity_id);
return Partial.error(`You must set an ${ENTITY_DOMAIN} entity`, this.config); 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( return Partial.error(
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`, `You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
this.config, 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 hourInstance = new Hour(hour, this.config.hour_step, this.config.hour_mode);
const minuteInstance = new Minute(minute, this.config.minute_step); const minuteInstance = new Minute(minute, this.config.minute_step);
const secondInstance = new Second(second, this.config.second_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> { private callHassService(): Promise<void> {
if (!this.hass) { 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', { const domain = computeDomain(this.entity!.entity_id);
entity_id: this.entity!.entity_id, const time =
time: this.time.value, 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 { static get styles(): CSSResult {
@@ -390,7 +418,9 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
_: HomeAssistant, _: HomeAssistant,
entities: Array<string>, entities: Array<string>,
): Omit<TimePickerCardConfig, 'type'> { ): 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 { return {
entity: datetimeEntity || '', entity: datetimeEntity || '',
+48
View File
@@ -17,6 +17,7 @@ class ErrorCardStub extends HTMLElement {
customElements.define('hui-error-card', ErrorCardStub); customElements.define('hui-error-card', ErrorCardStub);
const ENTITY_ID = 'input_datetime.alarm'; 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. */ /** YAML is untyped at runtime, so setConfig has to cope with shapes the type forbids. */
const invalidConfig = (config: Record<string, unknown>): TimePickerCardConfig => 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 }, 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 => const createHass = (entity: HassEntity = alarm(7)): HomeAssistant =>
({ ({
states: { [entity.entity_id]: entity }, states: { [entity.entity_id]: entity },
@@ -116,6 +124,28 @@ describe('time-picker-card', () => {
expect(inputValues(card)).toEqual(['09', '45']); 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 () => { it('renders an error card when the entity is missing', async () => {
card = await renderCard({ entity: 'input_datetime.missing' }); card = await renderCard({ entity: 'input_datetime.missing' });
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub; 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 () => { it('debounces the service call when a delay is configured', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { 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);
});
}); });