feat(actions)!: Support for the new action (assist) and all the future ones

This also fixes the latest translations missing and starts the work to remove custom-cards-helper from the dependencies.

BREAKING CHANGE: Requires HA 2023.4 minimum. Support for the new action format (`target` is also be supported), `service_data` should be renamed to `data` (but it still works with the old format)

Fix #711, #685
This commit is contained in:
Jérôme Wiedemann 2023-07-23 22:23:08 +00:00
parent 4351895cd3
commit d9c17a4065
24 changed files with 4983 additions and 2073 deletions

View File

@ -453,6 +453,8 @@ views:
type: entity-button
entity: switch.skylight
name: Default HASS
show_name: false
show_state: true
- type: custom:card-modder
styles:
card:
@ -460,6 +462,8 @@ views:
card:
type: 'custom:button-card'
entity: switch.skylight
show_state: true
show_name: false
- type: custom:card-modder
styles:
card:
@ -984,6 +988,7 @@ views:
name: more-info
tap_action:
action: more-info
entity: light.test_light
- type: 'custom:button-card'
entity: switch.skylight
name: call-service
@ -992,6 +997,19 @@ views:
service: switch.toggle
service_data:
entity_id: switch.skylight
- type: 'custom:button-card'
entity: switch.skylight
name: call-service
tap_action:
action: call-service
service: timer.start
target:
entity_id: timer.laundry
- type: 'custom:button-card'
entity: switch.skylight
name: assist
tap_action:
action: assist
- type: 'custom:button-card'
entity: switch.skylight
name: none
@ -1634,7 +1652,7 @@ views:
cards:
- type: horizontal-stack
cards:
- type: entity-button
- type: button-card
entity: switch.skylight
name: Default
- type: 'custom:button-card'

View File

@ -129,17 +129,18 @@ Lovelace Button card for your entities.
All the fields support templates, see [templates](#javascript-templates).
| Name | Type | Default | Supported options | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `action` | string | `toggle` | `more-info`, `toggle`, `call-service`, `none`, `navigate`, `url` | Action to perform |
| `entity` | string | none | Any entity id | **Only valid for `action: more-info`** to override the entity on which you want to call `more-info` |
| `navigation_path` | string | none | Eg: `/lovelace/0/` | Path to navigate to (e.g. `/lovelace/0/`) when action defined as navigate |
| `url_path` | string | none | Eg: `https://www.google.fr` | URL to open on click when action is `url`. The URL will open in a new tab |
| `service` | string | none | Any service | Service to call (e.g. `media_player.media_play_pause`) when `action` defined as `call-service` |
| `service_data` | object | none | Any service data | Service data to include (e.g. `entity_id: media_player.bedroom`) when `action` defined as `call-service`. If your `service_data` requires an `entity_id`, you can use the keywork `entity`, this will actually call the service on the entity defined in the main configuration of this card. Useful for [configuration templates](#configuration-templates) |
| `haptic` | string | none | `success`, `warning`, `failure`, `light`, `medium`, `heavy`, `selection` | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) |
| `repeat` | number | none | eg: `500` | For a hold_action, you can optionally configure the action to repeat while the button is being held down (for example, to repeatedly increase the volume of a media player). Define the number of milliseconds between repeat actions here. |
| `confirmation` | object | none | See [confirmation](#confirmation) | Display a confirmation popup, overrides the default `confirmation` object |
| Name | Type | Default | Supported options | Description |
| ------------------------ | ------ | -------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | string | `toggle` | `more-info`, `toggle`, `call-service`, `none`, `navigate`, `url`, `assist` | Action to perform |
| `entity` | string | none | Any entity id | **Only valid for `action: more-info`** to override the entity on which you want to call `more-info` |
| `target` | object | none | | Only works with `call-service`. Follows the [home-assistant syntax](https://www.home-assistant.io/docs/scripts/service-calls/#targeting-areas-and-devices) |
| `navigation_path` | string | none | Eg: `/lovelace/0/` | Path to navigate to (e.g. `/lovelace/0/`) when action defined as navigate |
| `url_path` | string | none | Eg: `https://www.google.fr` | URL to open on click when action is `url`. The URL will open in a new tab |
| `service` | string | none | Any service | Service to call (e.g. `media_player.media_play_pause`) when `action` defined as `call-service` |
| `data` or `service_data` | object | none | Any service data | Service data to include (e.g. `entity_id: media_player.bedroom`) when `action` defined as `call-service`. If your `data` requires an `entity_id`, you can use the keywork `entity`, this will actually call the service on the entity defined in the main configuration of this card. Useful for [configuration templates](#configuration-templates) |
| `haptic` | string | none | `success`, `warning`, `failure`, `light`, `medium`, `heavy`, `selection` | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) |
| `repeat` | number | none | eg: `500` | For a hold_action, you can optionally configure the action to repeat while the button is being held down (for example, to repeatedly increase the volume of a media player). Define the number of milliseconds between repeat actions here. |
| `confirmation` | object | none | See [confirmation](#confirmation) | Display a confirmation popup, overrides the default `confirmation` object |
### Confirmation
@ -1031,7 +1032,7 @@ Horizontal stack with :
tap_action:
action: call-service
service: media_player.volume_up
service_data:
data:
entity_id: media_player.living_room_speaker
- type: 'custom:button-card'
color_type: card
@ -1040,7 +1041,7 @@ Horizontal stack with :
tap_action:
action: call-service
service: media_player.volume_down
service_data:
data:
entity_id: media_player.living_room_speaker
- type: 'custom:button-card'
color_type: blank-card
@ -1115,7 +1116,7 @@ If you don't specify any operator, `==` will be used to match the current state
tap_action:
action: call-service
service: input_select.select_next
service_data:
data:
entity_id: input_select.cube_mode
show_state: true
state:

View File

@ -68,10 +68,12 @@
"dependencies": {
"@ctrl/tinycolor": "^3.1.6",
"@material/mwc-ripple": "^0.19.1",
"custom-card-helpers": "^1.7.0",
"custom-card-helpers": "^1.9.0",
"fast-copy": "^2.1.0",
"home-assistant-js-websocket": "^5.7.0",
"lit-element": "^2.4.0",
"lit-html": "^1.3.0"
"home-assistant-js-websocket": "^8.2.0",
"lit": "^2.7.6",
"lit-element": "^3.3.2",
"lit-html": "^2.7.5",
"memoize-one": "^6.0.0"
}
}

View File

@ -1,10 +1,13 @@
import { directive, PropertyPart } from 'lit-html';
import { PropertyPart, noChange } from 'lit-html';
// import '@material/mwc-ripple';
// tslint:disable-next-line
import { Ripple } from '@material/mwc-ripple';
import { myFireEvent } from './my-fire-event';
import { deepEqual } from './deep-equal';
import { AttributePart, Directive, DirectiveParameters, directive } from 'lit-html/directive';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
interface ActionHandler extends HTMLElement {
@ -254,14 +257,21 @@ const getActionHandler = (): ActionHandler => {
return actionhandler as ActionHandler;
};
export const actionHandlerBind = (element: ActionHandlerElement, options: ActionHandlerOptions): void => {
export const actionHandlerBind = (element: ActionHandlerElement, options?: ActionHandlerOptions) => {
const actionhandler: ActionHandler = getActionHandler();
if (!actionhandler) {
return;
}
actionhandler.bind(element, options);
};
export const actionHandler = directive(
class extends Directive {
update(part: AttributePart, [options]: DirectiveParameters<this>) {
actionHandlerBind(part.element as ActionHandlerElement, options);
return noChange;
}
export const actionHandler = directive((options: ActionHandlerOptions = {}) => (part: PropertyPart): void => {
actionHandlerBind(part.committer.element as ActionHandlerElement, options);
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
render(_options?: ActionHandlerOptions) {}
},
);

View File

@ -11,24 +11,15 @@ import {
queryAsync,
eventOptions,
} from 'lit-element';
import { ifDefined } from 'lit/directives/if-defined.js';
import { Ripple } from '@material/mwc-ripple';
import { RippleHandlers } from '@material/mwc-ripple/ripple-handlers';
import { styleMap, StyleInfo } from 'lit-html/directives/style-map';
import { unsafeHTML } from 'lit-html/directives/unsafe-html';
import { classMap, ClassInfo } from 'lit-html/directives/class-map';
import { HassEntity } from 'home-assistant-js-websocket';
import {
HomeAssistant,
handleClick,
timerTimeRemaining,
secondsToDuration,
durationToSeconds,
createThing,
fireEvent,
DOMAINS_TOGGLE,
LovelaceCard,
computeStateDomain,
} from 'custom-card-helpers';
import { timerTimeRemaining, createThing, DOMAINS_TOGGLE, computeStateDomain } from 'custom-card-helpers';
import { LovelaceCard } from './types/lovelace';
import {
ButtonCardConfig,
ExternalButtonCardConfig,
@ -38,7 +29,7 @@ import {
CustomFieldCard,
ButtonCardEmbeddedCards,
ButtonCardEmbeddedCardsConfig,
} from './types';
} from './types/types';
import { actionHandler } from './action-handler';
import {
computeDomain,
@ -52,17 +43,22 @@ import {
mergeStatesById,
getLovelace,
getLovelaceCast,
secondsToDuration,
durationToSeconds,
} from './helpers';
import { styles } from './styles';
import { myComputeStateDisplay } from './compute_state_display';
import { computeStateDisplay } from './compute_state_display';
import copy from 'fast-copy';
import * as pjson from '../package.json';
import { deepEqual } from './deep-equal';
import { stateColorCss } from './state_color';
import { ON } from './const';
import { ON } from './common/const';
import { handleAction } from './handle-action';
import { myFireEvent } from './my-fire-event';
import { HomeAssistant } from './types/homeassistant';
let helpers = (window as any).cardHelpers;
const helperPromise = new Promise(async (resolve) => {
const helperPromise = new Promise<void>(async (resolve) => {
if (helpers) resolve();
if ((window as any).loadCardHelpers) {
helpers = await (window as any).loadCardHelpers();
@ -157,7 +153,7 @@ class ButtonCard extends LitElement {
else {
const element = createThing(config);
helperPromise.then(() => {
fireEvent(element, 'll-rebuild', {});
myFireEvent(element, 'll-rebuild', {});
});
return element;
}
@ -175,7 +171,7 @@ class ButtonCard extends LitElement {
? this._objectEvalTemplate(this._stateObj, this._config!.variables)
: undefined;
return this._cardHtml();
} catch (e) {
} catch (e: any) {
if (e.stack) console.error(e.stack);
else console.error(e);
const errorCard = document.createElement('hui-error-card') as LovelaceCard;
@ -240,7 +236,7 @@ class ButtonCard extends LitElement {
}
}
private _computeTimeDisplay(stateObj: HassEntity): string | undefined {
private _computeTimeDisplay(stateObj: HassEntity): string | undefined | null {
if (!stateObj) {
return undefined;
}
@ -309,7 +305,7 @@ class ButtonCard extends LitElement {
this._evaledVariables,
html,
);
} catch (e) {
} catch (e: any) {
const funcTrimmed = func.length <= 100 ? func.trim() : `${func.trim().substring(0, 98)}...`;
e.message = `${e.name}: ${e.message} in '${funcTrimmed}'`;
e.name = 'ButtonCardJSTemplateError';
@ -493,30 +489,43 @@ class ButtonCard extends LitElement {
return this._getTemplateOrValue(state, name);
}
private _buildStateString(stateObj: HassEntity | undefined): string | undefined {
let stateString: string | undefined;
private _buildStateString(stateObj: HassEntity | undefined): string | undefined | null {
let stateString: string | undefined | null;
if (this._config!.show_state && stateObj && stateObj.state) {
const units = this._buildUnits(stateObj);
if (units) {
stateString = `${stateObj.state} ${units}`;
} else if (computeDomain(stateObj.entity_id) === 'timer') {
if (stateObj.state === 'idle' || this._timeRemaining === 0) {
stateString = myComputeStateDisplay(this._hass!, this._hass!.localize, stateObj, this._hass!.language);
stateString = computeStateDisplay(
this._hass!.localize,
stateObj,
this._hass!.locale,
this._hass!.config,
this._hass!.entities,
);
} else {
stateString = this._computeTimeDisplay(stateObj);
if (stateObj.state === 'paused') {
stateString += ` (${myComputeStateDisplay(
this._hass!,
stateString += ` (${computeStateDisplay(
this._hass!.localize,
stateObj,
this._hass!.language,
this._hass!.locale,
this._hass!.config,
this._hass!.entities,
)})`;
}
}
} else if (!this._config?.show_units && computeDomain(stateObj.entity_id) === 'sensor') {
stateString = stateObj.state;
} else {
stateString = myComputeStateDisplay(this._hass!, this._hass!.localize, stateObj, this._hass!.language);
stateString = computeStateDisplay(
this._hass!.localize,
stateObj,
this._hass!.locale,
this._hass!.config,
this._hass!.entities,
);
}
}
return stateString;
@ -893,7 +902,7 @@ class ButtonCard extends LitElement {
<ha-state-icon
.state=${state}
?data-domain=${domain}
data-state=${state?.state}
data-state=${ifDefined(state?.state)}
style=${styleMap(haIconStyle)}
.icon="${icon}"
id="icon"
@ -1075,6 +1084,15 @@ class ButtonCard extends LitElement {
});
return configEval;
};
if (configDuplicate[action]?.service_data?.entity_id === 'entity') {
configDuplicate[action].service_data.entity_id = config.entity;
}
if (configDuplicate[action]?.data?.entity_id === 'entity') {
configDuplicate[action].data.entity_id = config.entity;
}
if (configDuplicate[action]?.entity) {
configDuplicate.entity = configDuplicate[action].entity;
}
configDuplicate[action] = __evalObject(configDuplicate[action]);
if (!configDuplicate[action].confirmation && configDuplicate.confirmation) {
configDuplicate[action].confirmation = __evalObject(configDuplicate.confirmation);
@ -1090,32 +1108,32 @@ class ButtonCard extends LitElement {
// backward compatibility
@eventOptions({ passive: true })
private handleRippleActivate(evt?: Event): void {
this._ripple.then((r) => r && r.startPress && this._rippleHandlers.startPress(evt));
this._ripple.then((r) => r && typeof r.startPress === 'function' && this._rippleHandlers.startPress(evt));
}
private handleRippleDeactivate(): void {
this._ripple.then((r) => r && r.endPress && this._rippleHandlers.endPress());
this._ripple.then((r) => r && typeof r.endPress === 'function' && this._rippleHandlers.endPress());
}
private handleRippleFocus(): void {
this._ripple.then((r) => r && r.startFocus && this._rippleHandlers.startFocus());
this._ripple.then((r) => r && typeof r.startFocus === 'function' && this._rippleHandlers.startFocus());
}
private handleRippleBlur(): void {
this._ripple.then((r) => r && r.endFocus && this._rippleHandlers.endFocus());
this._ripple.then((r) => r && typeof r.endFocus === 'function' && this._rippleHandlers.endFocus());
}
private _handleAction(ev: any): void {
if (ev.detail?.action) {
switch (ev.detail.action) {
case 'tap':
this._handleTap();
break;
case 'hold':
this._handleHold();
break;
case 'double_tap':
this._handleDblTap();
const config = this._config;
if (!config) return;
const action = ev.detail.action;
const localAction = this._evalActions(config, `${action}_action`);
handleAction(this, this._hass!, localAction, action);
break;
default:
break;
@ -1123,24 +1141,6 @@ class ButtonCard extends LitElement {
}
}
private _handleTap(): void {
const config = this._config;
if (!config) return;
handleClick(this, this._hass!, this._evalActions(config, 'tap_action'), false, false);
}
private _handleHold(): void {
const config = this._config;
if (!config) return;
handleClick(this, this._hass!, this._evalActions(config, 'hold_action'), true, false);
}
private _handleDblTap(): void {
const config = this._config;
if (!config) return;
handleClick(this, this._hass!, this._evalActions(config, 'double_tap_action'), false, true);
}
private _handleUnlockType(ev): void {
const config = this._config as ButtonCardConfig;
if (!config) return;
@ -1153,12 +1153,12 @@ class ButtonCard extends LitElement {
const lock = this.shadowRoot!.getElementById('lock') as LitElement;
if (!lock) return;
if (this._config!.lock!.exemptions) {
if (!this._hass!.user.name || !this._hass!.user.id) return;
if (!this._hass!.user?.name || !this._hass!.user.id) return;
let matched = false;
this._config!.lock!.exemptions.forEach((e) => {
if (
(!matched && (e as ExemptionUserConfig).user === this._hass!.user.id) ||
(e as ExemptionUsernameConfig).username === this._hass!.user.name
(!matched && (e as ExemptionUserConfig).user === this._hass!.user?.id) ||
(e as ExemptionUsernameConfig).username === this._hass!.user?.name
) {
matched = true;
}

View File

@ -1,4 +1,6 @@
export const UNAVAILABLE = 'unavailable';
export const BINARY_STATE_ON = 'on';
export const BINARY_STATE_OFF = 'off';
const arrayLiteralIncludes = <T extends readonly unknown[]>(array: T) => (
searchElement: unknown,
fromIndex?: number,

41
src/common/duration.ts Normal file
View File

@ -0,0 +1,41 @@
const DAY_IN_MILLISECONDS = 86400000;
const leftPad = (num: number, digits = 2) => {
let paddedNum = '' + num;
for (let i = 1; i < digits; i++) {
paddedNum = parseInt(paddedNum) < 10 ** i ? `0${paddedNum}` : paddedNum;
}
return paddedNum;
};
export default function millisecondsToDuration(d: number): string | null {
const h = Math.floor(d / 1000 / 3600);
const m = Math.floor(((d / 1000) % 3600) / 60);
const s = Math.floor(((d / 1000) % 3600) % 60);
const ms = Math.floor(d % 1000);
if (h > 0) {
return `${h}:${leftPad(m)}:${leftPad(s)}`;
}
if (m > 0) {
return `${m}:${leftPad(s)}`;
}
if (s > 0 || ms > 0) {
return `${s}${ms > 0 ? `.${leftPad(ms, 3)}` : ``}`;
}
return null;
}
const HOUR_IN_MILLISECONDS = 3600000;
const MINUTE_IN_MILLISECONDS = 60000;
const SECOND_IN_MILLISECONDS = 1000;
export const UNIT_TO_MILLISECOND_CONVERT = {
ms: 1,
s: SECOND_IN_MILLISECONDS,
min: MINUTE_IN_MILLISECONDS,
h: HOUR_IN_MILLISECONDS,
d: DAY_IN_MILLISECONDS,
};
export const formatDuration = (duration: string, units: string): string =>
millisecondsToDuration(parseFloat(duration) * UNIT_TO_MILLISECOND_CONVERT[units]) || '0';

156
src/common/format_date.ts Normal file
View File

@ -0,0 +1,156 @@
import { HassConfig } from 'home-assistant-js-websocket';
import memoizeOne from 'memoize-one';
import { FrontendLocaleData, DateFormat } from '../types/translation';
// Tuesday, August 10
export const formatDateWeekdayDay = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateWeekdayDayMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayDayMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: 'long',
month: 'long',
day: 'numeric',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// August 10, 2021
export const formatDate = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateMem(locale, config.time_zone).format(dateObj);
const formatDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// 10/08/2021
export const formatDateNumeric = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) => {
const formatter = formatDateNumericMem(locale, config.time_zone);
if (locale.date_format === DateFormat.language || locale.date_format === DateFormat.system) {
return formatter.format(dateObj);
}
const parts = formatter.formatToParts(dateObj);
const literal = parts.find((value) => value.type === 'literal')?.value;
const day = parts.find((value) => value.type === 'day')?.value;
const month = parts.find((value) => value.type === 'month')?.value;
const year = parts.find((value) => value.type === 'year')?.value;
const lastPart = parts[parts.length - 1];
let lastLiteral = lastPart?.type === 'literal' ? lastPart?.value : '';
if (locale.language === 'bg' && locale.date_format === DateFormat.YMD) {
lastLiteral = '';
}
const formats = {
[DateFormat.DMY]: `${day}${literal}${month}${literal}${year}${lastLiteral}`,
[DateFormat.MDY]: `${month}${literal}${day}${literal}${year}${lastLiteral}`,
[DateFormat.YMD]: `${year}${literal}${month}${literal}${day}${lastLiteral}`,
};
return formats[locale.date_format];
};
const formatDateNumericMem = memoizeOne((locale: FrontendLocaleData, serverTimeZone: string) => {
const localeString = locale.date_format === DateFormat.system ? undefined : locale.language;
if (locale.date_format === DateFormat.language || locale.date_format === DateFormat.system) {
return new Intl.DateTimeFormat(localeString, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
});
}
return new Intl.DateTimeFormat(localeString, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
});
});
// Aug 10
export const formatDateShort = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateShortMem(locale, config.time_zone).format(dateObj);
const formatDateShortMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
day: 'numeric',
month: 'short',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// August 2021
export const formatDateMonthYear = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateMonthYearMem(locale, config.time_zone).format(dateObj);
const formatDateMonthYearMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
month: 'long',
year: 'numeric',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// August
export const formatDateMonth = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateMonthMem(locale, config.time_zone).format(dateObj);
const formatDateMonthMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
month: 'long',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// 2021
export const formatDateYear = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateYearMem(locale, config.time_zone).format(dateObj);
const formatDateYearMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
year: 'numeric',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// Monday
export const formatDateWeekday = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateWeekdayMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: 'long',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// Mon
export const formatDateWeekdayShort = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateWeekdayShortMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayShortMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: 'short',
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);

View File

@ -0,0 +1,77 @@
import { HassConfig } from 'home-assistant-js-websocket';
import memoizeOne from 'memoize-one';
import { FrontendLocaleData } from '../types/translation';
import { formatDateNumeric } from './format_date';
import { formatTime, useAmPm } from './format_time';
// August 9, 2021, 8:23 AM
export const formatDateTime = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateTimeMem(locale, config.time_zone).format(dateObj);
const formatDateTimeMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: useAmPm(locale) ? 'numeric' : '2-digit',
minute: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// Aug 9, 2021, 8:23 AM
export const formatShortDateTimeWithYear = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatShortDateTimeWithYearMem(locale, config.time_zone).format(dateObj);
const formatShortDateTimeWithYearMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: useAmPm(locale) ? 'numeric' : '2-digit',
minute: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// Aug 9, 8:23 AM
export const formatShortDateTime = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatShortDateTimeMem(locale, config.time_zone).format(dateObj);
const formatShortDateTimeMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
month: 'short',
day: 'numeric',
hour: useAmPm(locale) ? 'numeric' : '2-digit',
minute: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// August 9, 2021, 8:23:15 AM
export const formatDateTimeWithSeconds = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatDateTimeWithSecondsMem(locale, config.time_zone).format(dateObj);
const formatDateTimeWithSecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: useAmPm(locale) ? 'numeric' : '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// 9/8/2021, 8:23 AM
export const formatDateTimeNumeric = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
`${formatDateNumeric(dateObj, locale, config)}, ${formatTime(dateObj, locale, config)}`;

136
src/common/format_number.ts Normal file
View File

@ -0,0 +1,136 @@
import { HassEntity, HassEntityAttributeBase } from 'home-assistant-js-websocket';
import { EntityRegistryDisplayEntry } from '../types/homeassistant';
import { FrontendLocaleData, NumberFormat } from '../types/translation';
// Logic based on https://en.wikipedia.org/wiki/Percent_sign#Form_and_spacing
export const blankBeforePercent = (localeOptions: FrontendLocaleData): string => {
switch (localeOptions.language) {
case 'cz':
case 'de':
case 'fi':
case 'fr':
case 'sk':
case 'sv':
return ' ';
default:
return '';
}
};
export const round = (value: number, precision = 2): number => Math.round(value * 10 ** precision) / 10 ** precision;
/**
* Returns true if the entity is considered numeric based on the attributes it has
* @param stateObj The entity state object
*/
export const isNumericState = (stateObj: HassEntity): boolean => isNumericFromAttributes(stateObj.attributes);
export const isNumericFromAttributes = (attributes: HassEntityAttributeBase): boolean =>
!!attributes.unit_of_measurement || !!attributes.state_class;
export const numberFormatToLocale = (localeOptions: FrontendLocaleData): string | string[] | undefined => {
switch (localeOptions.number_format) {
case NumberFormat.comma_decimal:
return ['en-US', 'en']; // Use United States with fallback to English formatting 1,234,567.89
case NumberFormat.decimal_comma:
return ['de', 'es', 'it']; // Use German with fallback to Spanish then Italian formatting 1.234.567,89
case NumberFormat.space_comma:
return ['fr', 'sv', 'cs']; // Use French with fallback to Swedish and Czech formatting 1 234 567,89
case NumberFormat.system:
return undefined;
default:
return localeOptions.language;
}
};
/**
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
*
* @param num The number to format
* @param localeOptions The user-selected language and formatting, from `hass.locale`
* @param options Intl.NumberFormatOptions to use
*/
export const formatNumber = (
num: string | number,
localeOptions?: FrontendLocaleData,
options?: Intl.NumberFormatOptions,
): string => {
const locale = localeOptions ? numberFormatToLocale(localeOptions) : undefined;
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN =
Number.isNaN ||
function isNaN(input) {
return typeof input === 'number' && isNaN(input);
};
if (localeOptions?.number_format !== NumberFormat.none && !Number.isNaN(Number(num)) && Intl) {
try {
return new Intl.NumberFormat(locale, getDefaultFormatOptions(num, options)).format(Number(num));
} catch (err: any) {
// Don't fail when using "TEST" language
// eslint-disable-next-line no-console
console.error(err);
return new Intl.NumberFormat(undefined, getDefaultFormatOptions(num, options)).format(Number(num));
}
}
if (typeof num === 'string') {
return num;
}
return `${round(num, options?.maximumFractionDigits).toString()}${
options?.style === 'currency' ? ` ${options.currency}` : ''
}`;
};
/**
* Checks if the current entity state should be formatted as an integer based on the `state` and `step` attribute and returns the appropriate `Intl.NumberFormatOptions` object with `maximumFractionDigits` set
* @param entityState The state object of the entity
* @returns An `Intl.NumberFormatOptions` object with `maximumFractionDigits` set to 0, or `undefined`
*/
export const getNumberFormatOptions = (
entityState: HassEntity,
entity?: EntityRegistryDisplayEntry,
): Intl.NumberFormatOptions | undefined => {
const precision = entity?.display_precision;
if (precision != null) {
return {
maximumFractionDigits: precision,
minimumFractionDigits: precision,
};
}
if (Number.isInteger(Number(entityState.attributes?.step)) && Number.isInteger(Number(entityState.state))) {
return { maximumFractionDigits: 0 };
}
if (entityState.attributes.step != null) {
return { maximumFractionDigits: Math.ceil(Math.log10(1 / entityState.attributes.step)) };
}
return undefined;
};
/**
* Generates default options for Intl.NumberFormat
* @param num The number to be formatted
* @param options The Intl.NumberFormatOptions that should be included in the returned options
*/
export const getDefaultFormatOptions = (
num: string | number,
options?: Intl.NumberFormatOptions,
): Intl.NumberFormatOptions => {
const defaultOptions: Intl.NumberFormatOptions = {
maximumFractionDigits: 2,
...options,
};
if (typeof num !== 'string') {
return defaultOptions;
}
// Keep decimal trailing zeros if they are present in a string numeric value
if (!options || (options.minimumFractionDigits === undefined && options.maximumFractionDigits === undefined)) {
const digits = num.indexOf('.') > -1 ? num.split('.')[1].length : 0;
defaultOptions.minimumFractionDigits = digits;
defaultOptions.maximumFractionDigits = digits;
}
return defaultOptions;
};

71
src/common/format_time.ts Normal file
View File

@ -0,0 +1,71 @@
import { HassConfig } from 'home-assistant-js-websocket';
import memoizeOne from 'memoize-one';
import { FrontendLocaleData, TimeFormat } from '../types/translation';
export const useAmPm = memoizeOne((locale: FrontendLocaleData): boolean => {
if (locale.time_format === TimeFormat.language || locale.time_format === TimeFormat.system) {
const testLanguage = locale.time_format === TimeFormat.language ? locale.language : undefined;
const test = new Date().toLocaleString(testLanguage);
return test.includes('AM') || test.includes('PM');
}
return locale.time_format === TimeFormat.am_pm;
});
export const formatTime = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatTimeMem(locale, config.time_zone).format(dateObj);
const formatTimeMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
hour: 'numeric',
minute: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// 9:15:24 PM || 21:15:24
export const formatTimeWithSeconds = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatTimeWithSecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithSecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
hour: useAmPm(locale) ? 'numeric' : '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatTimeWeekdayMem(locale, config.time_zone).format(dateObj);
const formatTimeWeekdayMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language === 'en' && !useAmPm(locale) ? 'en-u-hc-h23' : locale.language, {
weekday: 'long',
hour: useAmPm(locale) ? 'numeric' : '2-digit',
minute: '2-digit',
hour12: useAmPm(locale),
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);
// 21:15
export const formatTime24h = (dateObj: Date, locale: FrontendLocaleData, config: HassConfig) =>
formatTime24hMem(locale, config.time_zone).format(dateObj);
const formatTime24hMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
// en-GB to fix Chrome 24:59 to 0:59 https://stackoverflow.com/a/60898146
new Intl.DateTimeFormat('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: false,
timeZone: locale.time_zone === 'server' ? serverTimeZone : undefined,
}),
);

View File

@ -0,0 +1,14 @@
import { HassEntity } from 'home-assistant-js-websocket';
export const supportsFeature = (stateObj: HassEntity, feature: number): boolean =>
supportsFeatureFromAttributes(stateObj.attributes, feature);
export const supportsFeatureFromAttributes = (
attributes: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
},
feature: number,
): boolean =>
// eslint-disable-next-line no-bitwise, @typescript-eslint/no-non-null-assertion
(attributes.supported_features! & feature) !== 0;

40
src/common/update.ts Normal file
View File

@ -0,0 +1,40 @@
import type { HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
import { BINARY_STATE_ON } from './const';
import { supportsFeature, supportsFeatureFromAttributes } from './supports-features';
export const UPDATE_SUPPORT_INSTALL = 1;
export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2;
export const UPDATE_SUPPORT_PROGRESS = 4;
export const UPDATE_SUPPORT_BACKUP = 8;
export const UPDATE_SUPPORT_RELEASE_NOTES = 16;
interface UpdateEntityAttributes extends HassEntityAttributeBase {
auto_update: boolean | null;
installed_version: string | null;
in_progress: boolean | number;
latest_version: string | null;
release_summary: string | null;
release_url: string | null;
skipped_version: string | null;
title: string | null;
}
export interface UpdateEntity extends HassEntityBase {
attributes: UpdateEntityAttributes;
}
export const updateUsesProgress = (entity: UpdateEntity): boolean =>
updateUsesProgressFromAttributes(entity.attributes);
export const updateUsesProgressFromAttributes = (attributes: { [key: string]: any }): boolean =>
supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) && typeof attributes.in_progress === 'number';
export const updateCanInstall = (entity: UpdateEntity, showSkipped = false): boolean =>
(entity.state === BINARY_STATE_ON || (showSkipped && Boolean(entity.attributes.skipped_version))) &&
supportsFeature(entity, UPDATE_SUPPORT_INSTALL);
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
updateUsesProgress(entity) || !!entity.attributes.in_progress;
export const updateIsInstallingFromAttributes = (attributes: { [key: string]: any }): boolean =>
updateUsesProgressFromAttributes(attributes) || !!attributes.in_progress;

View File

@ -1,115 +1,195 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { LocalizeFunc, HomeAssistant, formatDate, formatTime, formatDateTime } from 'custom-card-helpers';
import { computeDomain } from './helpers';
import { HassConfig, HassEntity } from 'home-assistant-js-websocket';
import { LocalizeFunc } from 'custom-card-helpers';
import { computeDomain, isNumericFromAttributes } from './helpers';
import { atLeastVersion } from './at_least_version';
import { formatNumber, getNumberFormatOptions, blankBeforePercent } from './common/format_number';
import { EntityRegistryDisplayEntry, FrontendLocaleData, HomeAssistant, TimeZone } from './types/homeassistant';
import { UNIT_TO_MILLISECOND_CONVERT, formatDuration } from './common/duration';
import { formatDateTime } from './common/format_date_time';
import { formatDate } from './common/format_date';
import { formatTime } from './common/format_time';
import { UPDATE_SUPPORT_PROGRESS, updateIsInstallingFromAttributes } from './common/update';
import { supportsFeatureFromAttributes } from './common/supports-features';
const UNAVAILABLE = 'unavailable';
const UNKNOWN = 'unknown';
function legacyComputeStateDisplay(localize: LocalizeFunc, stateObj: HassEntity): string | undefined {
let display: string | undefined;
const domain = computeDomain(stateObj.entity_id);
if (domain === 'binary_sensor') {
// Try device class translation, then default binary sensor translation
if (stateObj.attributes.device_class) {
display = localize(`state.${domain}.${stateObj.attributes.device_class}.${stateObj.state}`);
}
if (!display) {
display = localize(`state.${domain}.default.${stateObj.state}`);
}
} else if (stateObj.attributes.unit_of_measurement && !['unknown', 'unavailable'].includes(stateObj.state)) {
display = stateObj.state;
} else if (domain === 'zwave') {
if (['initializing', 'dead'].includes(stateObj.state)) {
display = localize(`state.zwave.query_stage.${stateObj.state}`, 'query_stage', stateObj.attributes.query_stage);
} else {
display = localize(`state.zwave.default.${stateObj.state}`);
}
} else {
display = localize(`state.${domain}.${stateObj.state}`);
}
// Fall back to default, component backend translation, or raw state if nothing else matches.
if (!display) {
display =
localize(`state.default.${stateObj.state}`) ||
localize(`component.${domain}.state.${stateObj.state}`) ||
stateObj.state;
}
return display;
}
export const myComputeStateDisplay = (
hass: HomeAssistant,
export const computeStateDisplaySingleEntity = (
localize: LocalizeFunc,
stateObj: HassEntity,
language: string,
): string | undefined => {
if (!atLeastVersion(hass.config.version, 0, 109)) {
return legacyComputeStateDisplay(localize, stateObj);
}
locale: FrontendLocaleData,
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
state?: string,
): string =>
computeStateDisplayFromEntityAttributes(
localize,
locale,
config,
entity,
stateObj.entity_id,
stateObj.attributes,
state !== undefined ? state : stateObj.state,
);
if (stateObj.state === UNKNOWN || stateObj.state === UNAVAILABLE) {
return localize(`state.default.${stateObj.state}`);
}
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
config: HassConfig,
entities: HomeAssistant['entities'],
state?: string,
): string => {
const entity = entities[stateObj.entity_id] as EntityRegistryDisplayEntry | undefined;
if (stateObj.attributes.unit_of_measurement) {
return `${stateObj.state} ${stateObj.attributes.unit_of_measurement}`;
}
const domain = computeDomain(stateObj.entity_id);
if (domain === 'input_datetime') {
let date: Date;
if (!stateObj.attributes.has_time) {
date = new Date(stateObj.attributes.year, stateObj.attributes.month - 1, stateObj.attributes.day);
return formatDate(date, language);
}
if (!stateObj.attributes.has_date) {
const now = new Date();
date = new Date(
// Due to bugs.chromium.org/p/chromium/issues/detail?id=797548
// don't use artificial 1970 year.
now.getFullYear(),
now.getMonth(),
now.getDay(),
stateObj.attributes.hour,
stateObj.attributes.minute,
);
return formatTime(date, language);
}
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day,
stateObj.attributes.hour,
stateObj.attributes.minute,
);
return formatDateTime(date, language);
}
if (!atLeastVersion(hass.config.version, 2023, 4)) {
return (
// Return device class translation
(stateObj.attributes.device_class &&
localize(`component.${domain}.state.${stateObj.attributes.device_class}.${stateObj.state}`)) ||
// Return default translation
localize(`component.${domain}.state._.${stateObj.state}`) ||
// We don't know! Return the raw state.
stateObj.state
);
}
return (
// Return device class translation
(stateObj.attributes.device_class &&
localize(`component.${domain}.entity_component.${stateObj.attributes.device_class}.state.${stateObj.state}`)) ||
// Return default translation
localize(`component.${domain}.entity_component._.state.${stateObj.state}`) ||
// We don't know! Return the raw state.
stateObj.state
return computeStateDisplayFromEntityAttributes(
localize,
locale,
config,
entity,
stateObj.entity_id,
stateObj.attributes,
state !== undefined ? state : stateObj.state,
);
};
export const computeStateDisplayFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
attributes: any,
state: string,
): string => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return localize(`state.default.${state}`);
}
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
if (isNumericFromAttributes(attributes)) {
// state is duration
if (
attributes.device_class === 'duration' &&
attributes.unit_of_measurement &&
UNIT_TO_MILLISECOND_CONVERT[attributes.unit_of_measurement]
) {
try {
return formatDuration(state, attributes.unit_of_measurement);
} catch (_err) {
// fallback to default
}
}
if (attributes.device_class === 'monetary') {
try {
return formatNumber(state, locale, {
style: 'currency',
currency: attributes.unit_of_measurement,
minimumFractionDigits: 2,
// Override monetary options with number format
...getNumberFormatOptions({ state, attributes } as HassEntity, entity),
});
} catch (_err) {
// fallback to default
}
}
const unit = !attributes.unit_of_measurement
? ''
: attributes.unit_of_measurement === '%'
? blankBeforePercent(locale) + '%'
: ` ${attributes.unit_of_measurement}`;
return `${formatNumber(state, locale, getNumberFormatOptions({ state, attributes } as HassEntity, entity))}${unit}`;
}
const domain = computeDomain(entityId);
if (domain === 'datetime') {
const time = new Date(state);
return formatDateTime(time, locale, config);
}
if (['date', 'input_datetime', 'time'].includes(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`.
// These are timezone agnostic, so we should NOT use the system timezone here.
try {
const components = state.split(' ');
if (components.length === 2) {
// Date and time.
return formatDateTime(new Date(components.join('T')), { ...locale, time_zone: TimeZone.local }, config);
}
if (components.length === 1) {
if (state.includes('-')) {
// Date only.
return formatDate(new Date(`${state}T00:00`), { ...locale, time_zone: TimeZone.local }, config);
}
if (state.includes(':')) {
// Time only.
const now = new Date();
return formatTime(
new Date(`${now.toISOString().split('T')[0]}T${state}`),
{ ...locale, time_zone: TimeZone.local },
config,
);
}
}
return state;
} catch (_e) {
// Formatting methods may throw error if date parsing doesn't go well,
// just return the state string in that case.
return state;
}
}
// `counter` `number` and `input_number` domains do not have a unit of measurement but should still use `formatNumber`
if (domain === 'counter' || domain === 'number' || domain === 'input_number') {
// Format as an integer if the value and step are integers
return formatNumber(state, locale, getNumberFormatOptions({ state, attributes } as HassEntity, entity));
}
// state is a timestamp
if (
['button', 'event', 'input_button', 'scene', 'stt', 'tts'].includes(domain) ||
(domain === 'sensor' && attributes.device_class === 'timestamp')
) {
try {
return formatDateTime(new Date(state), locale, config);
} catch (_err) {
return state;
}
}
if (domain === 'update') {
// When updating, and entity does not support % show "Installing"
// When updating, and entity does support % show "Installing (xx%)"
// When update available, show the version
// When the latest version is skipped, show the latest version
// When update is not available, show "Up-to-date"
// When update is not available and there is no latest_version show "Unavailable"
return state === 'on'
? updateIsInstallingFromAttributes(attributes)
? supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) &&
typeof attributes.in_progress === 'number'
? localize('ui.card.update.installing_with_progress', {
progress: attributes.in_progress,
})
: localize('ui.card.update.installing')
: attributes.latest_version
: attributes.skipped_version === attributes.latest_version
? attributes.latest_version ?? localize('state.default.unavailable')
: localize('ui.card.update.up_to_date');
}
return (
(entity?.translation_key &&
localize(`component.${entity.platform}.entity.${domain}.${entity.translation_key}.state.${state}`)) ||
// Return device class translation
(attributes.device_class &&
localize(`component.${domain}.entity_component.${attributes.device_class}.state.${state}`)) ||
// Return default translation
localize(`component.${domain}.entity_component._.state.${state}`) ||
// We don't know! Return the raw state.
state
);
};

28
src/handle-action.ts Normal file
View File

@ -0,0 +1,28 @@
import { myFireEvent } from './my-fire-event';
import { ActionConfig } from './types/types';
import { HomeAssistant } from './types/homeassistant';
export type ActionConfigParams = {
entity?: string;
camera_image?: string;
hold_action?: ActionConfig;
tap_action?: ActionConfig;
double_tap_action?: ActionConfig;
};
export const handleAction = async (
node: HTMLElement,
_hass: HomeAssistant,
config: ActionConfigParams,
action: string,
): Promise<void> => {
myFireEvent(node, 'hass-action', { config, action });
};
type ActionParams = { config: ActionConfigParams; action: string };
declare global {
interface HASSDomEvents {
'hass-action': ActionParams;
}
}

View File

@ -1,9 +1,9 @@
import { PropertyValues } from 'lit-element';
import tinycolor, { TinyColor } from '@ctrl/tinycolor';
import { HomeAssistant, LovelaceConfig } from 'custom-card-helpers';
import { StateConfig } from './types';
import { StateConfig } from './types/types';
import { HassEntity, HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
import { OFF, UNAVAILABLE, isUnavailableState } from './const';
import { OFF, UNAVAILABLE, isUnavailableState } from './common/const';
export function computeDomain(entityId: string): string {
return entityId.substr(0, entityId.indexOf('.'));
@ -331,3 +331,31 @@ export function computeCssValue(prop: string | string[], computedStyles: CSSStyl
}
return computedStyles.getPropertyValue(prop).trim() || undefined;
}
export function durationToSeconds(duration: string): number {
const parts = duration.split(':').map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
const leftPad = (num: number) => (num < 10 ? `0${num}` : num);
export function secondsToDuration(d: number): string | null {
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
if (h > 0) {
return `${h}:${leftPad(m)}:${leftPad(s)}`;
}
if (m > 0) {
return `${m}:${leftPad(s)}`;
}
if (s > 0) {
return '' + s;
}
return null;
}
export function isNumericFromAttributes(attributes: HassEntityAttributeBase): boolean {
return !!attributes.unit_of_measurement || !!attributes.state_class;
}

View File

@ -30,7 +30,7 @@
declare global {
// eslint-disable-next-line
interface HASSDomEvents { }
interface HASSDomEvents {}
}
export type ValidHassDomEvent = keyof HASSDomEvents;
@ -54,6 +54,7 @@ export interface HASSDomEvent<T> extends Event {
* `node` on which to fire the event (HTMLElement, defaults to `this`).
* @return {Event} The new event that was fired.
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const myFireEvent = <HassEvent extends ValidHassDomEvent>(
node: HTMLElement | Window,
type: HassEvent,
@ -63,8 +64,9 @@ export const myFireEvent = <HassEvent extends ValidHassDomEvent>(
cancelable?: boolean;
composed?: boolean;
},
): any => {
) => {
options = options || {};
// @ts-ignore
detail = detail === null || detail === undefined ? {} : detail;
const event = new Event(type, {
bubbles: options.bubbles === undefined ? true : options.bubbles,

View File

@ -1,6 +1,6 @@
/** Return an color representing a state. */
import { HassEntity } from 'home-assistant-js-websocket';
import { UNAVAILABLE } from './const';
import { UNAVAILABLE } from './common/const';
import { computeGroupDomain, GroupEntity } from './helpers';
import { computeCssVariable } from './helpers';
import { computeDomain, slugify } from './helpers';

278
src/types/homeassistant.ts Normal file
View File

@ -0,0 +1,278 @@
import {
Auth,
Connection,
HassConfig,
HassEntities,
HassServiceTarget,
HassServices,
MessageBase,
} from 'home-assistant-js-websocket';
export interface EntityRegistryDisplayEntry {
entity_id: string;
name?: string;
device_id?: string;
area_id?: string;
hidden?: boolean;
entity_category?: 'config' | 'diagnostic';
translation_key?: string;
platform?: string;
display_precision?: number;
}
export interface DeviceRegistryEntry {
id: string;
config_entries: string[];
connections: Array<[string, string]>;
identifiers: Array<[string, string]>;
manufacturer: string | null;
model: string | null;
name: string | null;
sw_version: string | null;
hw_version: string | null;
via_device_id: string | null;
area_id: string | null;
name_by_user: string | null;
entry_type: 'service' | null;
disabled_by: 'user' | 'integration' | 'config_entry' | null;
configuration_url: string | null;
}
export interface AreaRegistryEntry {
area_id: string;
name: string;
picture: string | null;
}
export interface ThemeSettings {
theme: string;
// Radio box selection for theme picker. Do not use in Lovelace rendering as
// it can be undefined == auto.
// Property hass.themes.darkMode carries effective current mode.
dark?: boolean;
primaryColor?: string;
accentColor?: string;
}
export interface PanelInfo<T = Record<string, any> | null> {
component_name: string;
config: T;
icon: string | null;
title: string | null;
url_path: string;
}
export interface Panels {
[name: string]: PanelInfo;
}
export interface Resources {
[language: string]: Record<string, string>;
}
export interface Translation {
nativeName: string;
isRTL: boolean;
hash: string;
}
export interface TranslationMetadata {
fragments: string[];
translations: {
[lang: string]: Translation;
};
}
export interface Credential {
auth_provider_type: string;
auth_provider_id: string;
}
export interface MFAModule {
id: string;
name: string;
enabled: boolean;
}
export interface CurrentUser {
id: string;
is_owner: boolean;
is_admin: boolean;
name: string;
credentials: Credential[];
mfa_modules: MFAModule[];
}
export interface ServiceCallRequest {
domain: string;
service: string;
serviceData?: Record<string, any>;
target?: HassServiceTarget;
}
export interface Context {
id: string;
parent_id?: string;
user_id?: string | null;
}
export interface ServiceCallResponse {
context: Context;
}
export interface HomeAssistant {
auth: Auth;
connection: Connection;
connected: boolean;
states: HassEntities;
entities: { [id: string]: EntityRegistryDisplayEntry };
devices: { [id: string]: DeviceRegistryEntry };
areas: { [id: string]: AreaRegistryEntry };
services: HassServices;
config: HassConfig;
themes: Themes;
selectedTheme: ThemeSettings | null;
panels: Panels;
panelUrl: string;
// i18n
// current effective language in that order:
// - backend saved user selected language
// - language in local app storage
// - browser language
// - english (en)
language: string;
// local stored language, keep that name for backward compatibility
selectedLanguage: string | null;
locale: FrontendLocaleData;
resources: Resources;
localize: LocalizeFunc;
translationMetadata: TranslationMetadata;
suspendWhenHidden: boolean;
enableShortcuts: boolean;
vibrate: boolean;
dockedSidebar: 'docked' | 'always_hidden' | 'auto';
defaultPanel: string;
moreInfoEntityId: string | null;
user?: CurrentUser;
hassUrl(path?): string;
callService(
domain: ServiceCallRequest['domain'],
service: ServiceCallRequest['service'],
serviceData?: ServiceCallRequest['serviceData'],
target?: ServiceCallRequest['target'],
): Promise<ServiceCallResponse>;
callApi<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
parameters?: Record<string, any>,
headers?: Record<string, string>,
): Promise<T>;
fetchWithAuth(path: string, init?: Record<string, any>): Promise<Response>;
sendWS(msg: MessageBase): void;
callWS<T>(msg: MessageBase): Promise<T>;
loadBackendTranslation(
category: TranslationCategory,
integration?: string | string[],
configFlow?: boolean,
): Promise<LocalizeFunc>;
}
export enum NumberFormat {
language = 'language',
system = 'system',
comma_decimal = 'comma_decimal',
decimal_comma = 'decimal_comma',
space_comma = 'space_comma',
none = 'none',
}
export enum TimeFormat {
language = 'language',
system = 'system',
am_pm = '12',
twenty_four = '24',
}
export enum TimeZone {
local = 'local',
server = 'server',
}
export enum DateFormat {
language = 'language',
system = 'system',
DMY = 'DMY',
MDY = 'MDY',
YMD = 'YMD',
}
export enum FirstWeekday {
language = 'language',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
sunday = 'sunday',
}
export interface FrontendLocaleData {
language: string;
number_format: NumberFormat;
time_format: TimeFormat;
date_format: DateFormat;
first_weekday: FirstWeekday;
time_zone: TimeZone;
}
declare global {
interface FrontendUserData {
language: FrontendLocaleData;
}
}
export type TranslationCategory =
| 'title'
| 'state'
| 'entity'
| 'entity_component'
| 'config'
| 'config_panel'
| 'options'
| 'device_automation'
| 'mfa_setup'
| 'system_health'
| 'device_class'
| 'application_credentials'
| 'issues'
| 'selector';
export type LocalizeFunc = (key: string, ...args: any[]) => string;
export interface ThemeVars {
// Incomplete
'primary-color': string;
'text-primary-color': string;
'accent-color': string;
[key: string]: string;
}
export type Theme = ThemeVars & {
modes?: {
light?: ThemeVars;
dark?: ThemeVars;
};
};
export interface Themes {
default_theme: string;
default_dark_theme: string | null;
themes: Record<string, Theme>;
// Currently effective dark mode. Will never be undefined. If user selected "auto"
// in theme picker, this property will still contain either true or false based on
// what has been determined via system preferences and support from the selected theme.
darkMode: boolean;
// Currently globally active theme name
theme: string;
}

235
src/types/lovelace.ts Normal file
View File

@ -0,0 +1,235 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HassEventBase, HassServiceTarget } from 'home-assistant-js-websocket';
import { FrontendLocaleData, HomeAssistant } from './homeassistant';
import { Constructor } from 'lit-element';
export interface Lovelace {
config: LovelaceConfig;
// If not set, a strategy was used to generate everything
rawConfig: LovelaceConfig | undefined;
editMode: boolean;
urlPath: string | null;
mode: 'generated' | 'yaml' | 'storage';
locale: FrontendLocaleData;
enableFullEditMode: () => void;
setEditMode: (editMode: boolean) => void;
saveConfig: (newConfig: LovelaceConfig) => Promise<void>;
deleteConfig: () => Promise<void>;
}
export interface LovelaceCard extends HTMLElement {
hass?: HomeAssistant;
isPanel?: boolean;
editMode?: boolean;
getCardSize(): number | Promise<number>;
setConfig(config: LovelaceCardConfig): void;
}
export interface LovelaceCardConstructor extends Constructor<LovelaceCard> {
getStubConfig?: (hass: HomeAssistant, entities: string[], entitiesFallback: string[]) => LovelaceCardConfig;
getConfigElement?: () => LovelaceCardEditor;
}
export interface LovelaceCardEditor extends LovelaceGenericElementEditor {
setConfig(config: LovelaceCardConfig): void;
}
export interface LovelaceGenericElementEditor extends HTMLElement {
hass?: HomeAssistant;
lovelace?: LovelaceConfig;
setConfig(config: any): void;
focusYamlEditor?: () => void;
}
export interface LovelacePanelConfig {
mode: 'yaml' | 'storage';
}
export interface LovelaceConfig {
title?: string;
strategy?: {
type: string;
options?: Record<string, unknown>;
};
views: LovelaceViewConfig[];
background?: string;
}
export interface LegacyLovelaceConfig extends LovelaceConfig {
resources?: LovelaceResource[];
}
export interface LovelaceResource {
id: string;
type: 'css' | 'js' | 'module' | 'html';
url: string;
}
export interface LovelaceResourcesMutableParams {
res_type: LovelaceResource['type'];
url: string;
}
export type LovelaceDashboard = LovelaceYamlDashboard | LovelaceStorageDashboard;
interface LovelaceGenericDashboard {
id: string;
url_path: string;
require_admin: boolean;
show_in_sidebar: boolean;
icon?: string;
title: string;
}
export interface LovelaceYamlDashboard extends LovelaceGenericDashboard {
mode: 'yaml';
filename: string;
}
export interface LovelaceStorageDashboard extends LovelaceGenericDashboard {
mode: 'storage';
}
export interface LovelaceDashboardMutableParams {
require_admin: boolean;
show_in_sidebar: boolean;
icon?: string;
title: string;
}
export interface LovelaceDashboardCreateParams extends LovelaceDashboardMutableParams {
url_path: string;
mode: 'storage';
}
export interface LovelaceViewConfig {
index?: number;
title?: string;
type?: string;
strategy?: {
type: string;
options?: Record<string, unknown>;
};
cards?: LovelaceCardConfig[];
path?: string;
icon?: string;
theme?: string;
panel?: boolean;
background?: string;
visible?: boolean | ShowViewConfig[];
}
export interface LovelaceViewElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
narrow?: boolean;
index?: number;
cards?: Array<LovelaceCard>;
isStrategy: boolean;
setConfig(config: LovelaceViewConfig): void;
}
export interface ShowViewConfig {
user?: string;
}
export interface LovelaceBadgeConfig {
type?: string;
[key: string]: any;
}
export interface LovelaceCardConfig {
index?: number;
view_index?: number;
view_layout?: any;
type: string;
[key: string]: any;
}
export interface ToggleActionConfig extends BaseActionConfig {
action: 'toggle';
}
export interface CallServiceActionConfig extends BaseActionConfig {
action: 'call-service';
service: string;
target?: HassServiceTarget;
// "service_data" is kept for backwards compatibility. Replaced by "data".
service_data?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface NavigateActionConfig extends BaseActionConfig {
action: 'navigate';
navigation_path: string;
}
export interface UrlActionConfig extends BaseActionConfig {
action: 'url';
url_path: string;
}
export interface MoreInfoActionConfig extends BaseActionConfig {
action: 'more-info';
}
export interface NoActionConfig extends BaseActionConfig {
action: 'none';
}
export interface CustomActionConfig extends BaseActionConfig {
action: 'fire-dom-event';
}
export interface AssistActionConfig extends BaseActionConfig {
action: 'assist';
pipeline_id?: string;
start_listening?: boolean;
}
export interface BaseActionConfig {
action: string;
confirmation?: ConfirmationRestrictionConfig;
}
export interface ConfirmationRestrictionConfig {
text?: string;
exemptions?: RestrictionConfig[];
}
export interface RestrictionConfig {
user: string;
}
export type ActionConfig =
| ToggleActionConfig
| CallServiceActionConfig
| NavigateActionConfig
| UrlActionConfig
| MoreInfoActionConfig
| AssistActionConfig
| NoActionConfig
| CustomActionConfig;
type LovelaceUpdatedEvent = HassEventBase & {
event_type: 'lovelace_updated';
data: {
url_path: string | null;
mode: 'yaml' | 'storage';
};
};
export interface WindowWithLovelaceProm extends Window {
llConfProm?: Promise<LovelaceConfig>;
llResProm?: Promise<LovelaceResource[]>;
}
export interface ActionHandlerOptions {
hasHold?: boolean;
hasDoubleClick?: boolean;
disabled?: boolean;
}
export interface ActionHandlerDetail {
action: 'hold' | 'tap' | 'double_tap';
}

64
src/types/translation.ts Normal file
View File

@ -0,0 +1,64 @@
export enum NumberFormat {
language = 'language',
system = 'system',
comma_decimal = 'comma_decimal',
decimal_comma = 'decimal_comma',
space_comma = 'space_comma',
none = 'none',
}
export enum TimeFormat {
language = 'language',
system = 'system',
am_pm = '12',
twenty_four = '24',
}
export enum TimeZone {
local = 'local',
server = 'server',
}
export enum DateFormat {
language = 'language',
system = 'system',
DMY = 'DMY',
MDY = 'MDY',
YMD = 'YMD',
}
export enum FirstWeekday {
language = 'language',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
sunday = 'sunday',
}
export interface FrontendLocaleData {
language: string;
number_format: NumberFormat;
time_format: TimeFormat;
date_format: DateFormat;
first_weekday: FirstWeekday;
time_zone: TimeZone;
}
export type TranslationCategory =
| 'title'
| 'state'
| 'entity'
| 'entity_component'
| 'config'
| 'config_panel'
| 'options'
| 'device_automation'
| 'mfa_setup'
| 'system_health'
| 'device_class'
| 'application_credentials'
| 'issues'
| 'selector';

View File

@ -1,4 +1,6 @@
import { ActionConfig, LovelaceCardConfig, LovelaceCard } from 'custom-card-helpers';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LovelaceCardConfig, LovelaceCard } from './lovelace';
import { HassServiceTarget } from 'home-assistant-js-websocket';
export interface ButtonCardConfig {
template?: string | string[];
@ -162,3 +164,69 @@ export interface ButtonCardEmbeddedCards {
export interface ButtonCardEmbeddedCardsConfig {
[key: string]: string | undefined;
}
export interface ToggleActionConfig extends BaseActionConfig {
action: 'toggle';
}
export interface CallServiceActionConfig extends BaseActionConfig {
action: 'call-service';
service: string;
target?: HassServiceTarget;
// "service_data" is kept for backwards compatibility. Replaced by "data".
service_data?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface NavigateActionConfig extends BaseActionConfig {
action: 'navigate';
navigation_path: string;
}
export interface UrlActionConfig extends BaseActionConfig {
action: 'url';
url_path: string;
}
export interface MoreInfoActionConfig extends BaseActionConfig {
action: 'more-info';
}
export interface NoActionConfig extends BaseActionConfig {
action: 'none';
}
export interface CustomActionConfig extends BaseActionConfig {
action: 'fire-dom-event';
}
export interface AssistActionConfig extends BaseActionConfig {
action: 'assist';
pipeline_id?: string;
start_listening?: boolean;
}
export interface BaseActionConfig {
action: string;
confirmation?: ConfirmationRestrictionConfig;
repeat?: number;
}
export interface ConfirmationRestrictionConfig {
text?: string;
exemptions?: RestrictionConfig[];
}
export interface RestrictionConfig {
user: string;
}
export type ActionConfig =
| ToggleActionConfig
| CallServiceActionConfig
| NavigateActionConfig
| UrlActionConfig
| MoreInfoActionConfig
| AssistActionConfig
| NoActionConfig
| CustomActionConfig;

View File

@ -3,11 +3,7 @@
"target": "es2017",
"module": "esnext",
"moduleResolution": "node",
"lib": [
"es2017",
"dom",
"dom.iterable"
],
"lib": ["es2017", "dom", "dom.iterable"],
"plugins": [
{
"name": "ts-lit-plugin"
@ -23,9 +19,7 @@
"resolveJsonModule": true,
"experimentalDecorators": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"allowSyntheticDefaultImports": true
},
"include": [
"src/*"
]
"include": ["src/*", "src/**/*"]
}

5309
yarn.lock

File diff suppressed because it is too large Load Diff