Compare commits

...
3 Commits
Author SHA1 Message Date
GeorgeSG 44cb92bba4 Merge pull request #68 from AcMetelka/master
Feature to accept Time HA entity.
2026-10-05 09:01:19 +03:00
Jindrich.Stepan 326c195dfd Use generic alarm entity and consistent time domain labels 2026-10-05 00:52:32 +02:00
Jindrich.Stepan 81ca4cfb53 Feature: accepts time entity, not only input_datetime. 2026-10-05 00:31:27 +02:00
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).
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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 || '',
+48
View File
@@ -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);
});
});