diff --git a/README.md b/README.md index d066f1d..a8b7f20 100644 --- a/README.md +++ b/README.md @@ -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 ![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 -| 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 diff --git a/src/const.ts b/src/const.ts index 8e892ed..8891341 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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; diff --git a/src/editor.ts b/src/editor.ts index da9284f..fc02f7b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -5,7 +5,7 @@ import { customElement, property } from 'lit/decorators.js'; import { TimePickerCardConfig } from './types'; const NAME_TO_LABEL_MAP: Record = { - 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', diff --git a/src/time-picker-card.ts b/src/time-picker-card.ts index 840eaf5..31f4958 100644 --- a/src/time-picker-card.ts +++ b/src/time-picker-card.ts @@ -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 { 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, ): Omit { - 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 || '', diff --git a/test/time-picker-card.test.ts b/test/time-picker-card.test.ts index 92e53a0..563a4ee 100644 --- a/test/time-picker-card.test.ts +++ b/test/time-picker-card.test.ts @@ -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): 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('.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); + }); });