diff --git a/src/models/Hour.ts b/src/models/Hour.ts new file mode 100644 index 0000000..38c3743 --- /dev/null +++ b/src/models/Hour.ts @@ -0,0 +1,10 @@ +import { TimeUnit } from './TimeUnit'; + +export class Hour extends TimeUnit { + private static readonly DEFAULT_STEP = 1; + private static readonly MAX = 24; + + constructor(value: number, step = Hour.DEFAULT_STEP) { + super(value, step, Hour.MAX); + } +} diff --git a/src/models/Minute.ts b/src/models/Minute.ts new file mode 100644 index 0000000..4ff181a --- /dev/null +++ b/src/models/Minute.ts @@ -0,0 +1,10 @@ +import { TimeUnit } from './TimeUnit'; + +export class Minute extends TimeUnit { + private static readonly DEFAULT_STEP = 5; + private static readonly MAX = 50; + + constructor(value: number, step = Minute.DEFAULT_STEP) { + super(value, step, Minute.MAX); + } +} diff --git a/src/models/TimeUnit.ts b/src/models/TimeUnit.ts new file mode 100644 index 0000000..78b87b9 --- /dev/null +++ b/src/models/TimeUnit.ts @@ -0,0 +1,44 @@ +import { Direction } from '../types'; + +export abstract class TimeUnit { + constructor(private _value: number, private _step: number, private _limit: number) {} + + get value(): number { + return this._value; + } + + setStringValue(stringValue: string): void { + if (this.isValidString(stringValue)) { + this.setValue(parseInt(stringValue)); + } + } + + stepUpdate(direction: Direction): void { + direction === Direction.UP ? this.increment() : this.decrement(); + } + + increment(): void { + this.setValue(this.value + this._step); + } + + decrement(): void { + this.setValue(this.value - this._step); + } + + toString(): string { + return this.value < 10 ? `0${this.value}` : this.value.toString(); + } + + private isValidString(valueStr: string): boolean { + const value = parseInt(valueStr); + return !isNaN(value) && value >= 0 && value <= this._limit; + } + + private setValue(newValue: number): void { + if (newValue >= this._limit || newValue < 0) { + newValue = (newValue + this._limit) % this._limit; + } + + this._value = newValue; + } +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..e973ccf --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,3 @@ +export { TimeUnit } from './TimeUnit'; +export { Hour } from './Hour'; +export { Minute } from './Minute'; diff --git a/src/partials.ts b/src/partials.ts new file mode 100644 index 0000000..42e49ca --- /dev/null +++ b/src/partials.ts @@ -0,0 +1,9 @@ +import { html, TemplateResult } from 'lit-element'; +import { TimePickerCardConfig } from './types'; + +export class Partial { + static error(error: string, origConfig: TimePickerCardConfig): TemplateResult { + const config = { error, origConfig }; + return html``; + } +} diff --git a/src/styles.ts b/src/styles.ts index 92d24b3..28eef57 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,8 +1,64 @@ import { css } from 'lit-element'; export const styles = css` - .card-warning { - background-color: var(--google-red-500, #ef5350); - color: #fff; + .time-picker-ha-card { + padding: 16px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .time-form { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 8px; + } + + .time-picker-icon-up, + .time-picker-icon-down { + cursor: pointer; + } + + .time-picker-icon-up { + padding: 8px; + } + + .time-picker-icon-down { + margin-top: 8px; + padding: 8px; + } + + .time-input { + width: 30px; + padding: 8px; + background: var(--card-background-color, transparent); + border: 0; + color: var(--text-color, #fff); + border-bottom: 1px solid var(--text-color, #fff); + text-align: center; + font-size: 1em; + } + + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type='number'] { + -moz-appearance: textfield; + } + + input[type='number']:focus { + outline: none; + } + + input[type='number']:invalid { + box-shadow: none; + outline: none; + border: 0; + border-bottom: 2px solid red; } `; diff --git a/src/time-picker-card.ts b/src/time-picker-card.ts index 40a1db8..8a07b3e 100644 --- a/src/time-picker-card.ts +++ b/src/time-picker-card.ts @@ -1,8 +1,11 @@ import { HomeAssistant } from 'custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResult, customElement, html, LitElement, property, TemplateResult } from 'lit-element'; import { CARD_VERSION } from './const'; +import { Hour, Minute, TimeUnit } from './models'; +import { Partial } from './partials'; import { styles } from './styles'; -import { TimePickerCardConfig } from './types'; +import { Direction, TimePickerCardConfig } from './types'; console.info( `%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `, @@ -14,13 +17,69 @@ console.info( export class TimePickerCard extends LitElement { @property() private hass?: HomeAssistant; @property() private config?: TimePickerCardConfig; + @property() private hour?: Hour; + @property() private minute?: Minute; + + private get entity(): HassEntity | undefined { + if (!this.config) { + return; + } + + return this.hass?.states[this.config!.entity]; + } render(): TemplateResult | null { if (!this.config || !this.hass) { return null; } - return html`TimePickerCard`; + if (!this.entity?.entity_id.startsWith('input_datetime')) { + return Partial.error('You must set an input_datetime entity', this.config); + } + + if (!this.entity.attributes.has_time) { + return Partial.error( + 'You must set an input_datetime entity that sets has_time: true', + this.config + ); + } + + this.hour = new Hour(this.entity?.attributes.hour ?? 0, this.config.hour_step); + this.minute = new Minute(this.entity?.attributes.minute ?? 0, this.config.minute_step); + + return html` + +
+ ${this.renderStepChanger(Direction.UP, this.hour)} + + ${this.renderStepChanger(Direction.DOWN, this.hour)} +
+
+ : +
+
+ ${this.renderStepChanger(Direction.UP, this.minute)} + + ${this.renderStepChanger(Direction.DOWN, this.minute)} +
+
+ `; } setConfig(config): void { @@ -29,7 +88,7 @@ export class TimePickerCard extends LitElement { } if (!config.entity) { - throw new Error('You need to set an entity'); + throw new Error('You must set an entity'); } this.config = config; @@ -39,6 +98,45 @@ export class TimePickerCard extends LitElement { return 3; } + onHourChange({ target: { value } }: { target: HTMLInputElement }): void { + this.hour!.setStringValue(value); + this.callService(); + } + + onMinuteChange({ target: { value } }: { target: HTMLInputElement }): void { + this.minute!.setStringValue(value); + this.callService(); + } + + private renderStepChanger(direction: Direction, unit: TimeUnit): TemplateResult { + const onIconClick = (): void => { + unit.stepUpdate(direction); + this.callService(); + }; + + const className = `time-picker-icon-${direction}`; + + return html` +
+ + +
+ `; + } + + private callService(): Promise { + if (!this.hass) { + throw new Error('Unable to update datetime'); + } + + const time = `${this.hour!.value}:${this.minute!.value}:00`; + + return this.hass.callService('input_datetime', 'set_datetime', { + entity_id: this.entity?.entity_id, + time, + }); + } + static get styles(): CSSResult { return styles; } diff --git a/src/types.ts b/src/types.ts index 95415be..ce98d78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,10 @@ export interface TimePickerCardConfig { entity: string; + hour_step?: number; + minute_step?: number; +} + +export enum Direction { + UP = 'up', + DOWN = 'down', }