Skip to content

Commit

Permalink
Add support for async variables
Browse files Browse the repository at this point in the history
Co-authored-by: Paul Donohue <[email protected]>
Co-authored-by: Mike A. <[email protected]>
  • Loading branch information
PaulSD and malmeloo committed Feb 22, 2025
1 parent 37361ea commit e812ef3
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 21 deletions.
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,12 @@ elements:
type: icon
icon: "${vars[0] === 'on' ? 'mdi:home' : 'mdi:circle'}"
style:
'--paper-item-icon-color': '${ states[''sensor.light_icon_color''].state }'
'--paper-item-icon-color': "${ states['sensor.light_icon_color'].state }"
style:
top: 47%
left: 75%
```

The `style` object on the element configuration is applied to the element itself, the `style` object on the `config-template-card` is applied to the surrounding card. Both can contain templated values. For example, in order to place the card properly, the `top` and `left` attributes must always be configured on the `config-template-card`.

### Entities card example
Expand Down Expand Up @@ -196,7 +197,35 @@ card:
entities:
- entity: climate.ecobee
name: '${ setTempMessage("House: ", currentTemp) }'
````
```

### Asynchronous functions

Asynchronous functions can be used in most templates.

```yaml
type: 'custom:config-template-card'
entities:
- light.bed_light
- light.porch_light
card:
type: entities
entities:
- entity: light.bed_light
name: "${(async () => states['light.bed_light'].state === 'on' ? 'Bed Light On' : 'Bed Light Off' )();}"
- entity: light.porch_light
name: "${(async () => { return states['light.bed_light'].state === 'on' ? 'Porch Light On' : 'Porch Light Off'; })();}"
```

Card rendering will be delayed until all asynchronous functions (in all templates) have completed, so long-running asynchronous functions may prevent the card from rendering on page load or delay updates to the card.

When defining `staticVariables` that reference other (previously defined) `svars`, any referenced `svars` that use asynchronous functions will be `null`.

Similarly, when defining `variables` that reference other (previously defined) `vars`, any referenced `vars` that use asynchronous functions will be `null`. However, any referenced `svars` that use asynchronous functions will have complete/settled values.

When defining `entities` that use templates, any `entities` templates that use asynchronous functions will be ignored, and any referenced `vars` that use asynchronous functions will be `null`. However, any referenced `svars` that use asynchronous functions will have complete/settled values.

(The reason that asynchronous functions cannot be used for `entities` is that Lit does not support asynchronous functions in `shouldUpdate()` where `entities` is evaluated and used. The alternative [lovelace-card-templater](https://github.com/gadgetchnnel/lovelace-card-templater) cannot support templates in its `entities` for the same reason; It uses API calls to render templates, and API calls require the use of asynchronous functions.)

### Dashboard wide variables

Expand Down
150 changes: 131 additions & 19 deletions src/config-template-card.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { LitElement, html, TemplateResult, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { computeCardSize, HomeAssistant, LovelaceCard } from 'custom-card-helpers';

import { Config, SVarMgr, VarMgr, Vars, ObjMap } from './types';
import { VERSION } from './version';
import { assertNotNull, isString } from './util';
import { assertNotNull, isString, isPromise, somePromise } from './util';

console.info(
`%c CONFIG-TEMPLATE-CARD \n%c Version ${VERSION} `,
Expand Down Expand Up @@ -130,9 +131,19 @@ export class ConfigTemplateCard extends LitElement {
// _initSVars() requires hass and _config
if (!this.hass || !this._config) { return false; }

// shouldUpdate() requires _svarMgr
// shouldUpdate() requires _svarMgr to be settled
if (!this._svarMgr) {
this._svarMgr = this._evaluateVars(true);
if (this._svarMgr._svarsPromise) {
void this._svarMgr._svarsPromise.then((v) => {
// Explicitly trigger an update after svars has settled
this.requestUpdate();
return v;
});
return false;
}
} else {
if (this._svarMgr._svarsPromise) { return false; }
}

// render() requires hass, _config, and _helpers
Expand All @@ -154,7 +165,7 @@ export class ConfigTemplateCard extends LitElement {
const varMgr = this._evaluateVars(false);
// Cache the evaluated variables to avoid requiring render() to evaluate them again
this._tmpVarMgr = varMgr;
const entities = this._evaluateStructure(varMgr, this._config.entities);
const entities = this._evaluateStructure(varMgr, this._config.entities, 'immediate');
for (const entity of entities) {
if (this.hass && oldHass.states[entity] !== this.hass.states[entity]) {
return true;
Expand All @@ -174,10 +185,16 @@ export class ConfigTemplateCard extends LitElement {
this._tmpVarMgr = undefined;
if (!varMgr) { varMgr = this._evaluateVars(false); } // Shouldn't happen

return html`${until(this._getCardElement(varMgr))}`;
}

private async _getCardElement(varMgr: VarMgr): Promise<TemplateResult> {
assertNotNull(this._config); // TypeScript can't detect the gate in _initialize()

if (varMgr._varsPromise) { await varMgr._varsPromise; }

let configSection = (this._config.card ?? this._config.row ?? this._config.element);
configSection = this._evaluateStructure(varMgr, configSection);
configSection = await this._evaluateStructure(varMgr, configSection);

const element = this._config.card
? this._helpers.createCardElement(configSection)
Expand All @@ -189,7 +206,7 @@ export class ConfigTemplateCard extends LitElement {
if (this._config.element) {
if (this._config.style) {
let style = this._config.style;
style = this._evaluateStructure(varMgr, style);
style = await this._evaluateStructure(varMgr, style);
Object.keys(style).forEach((prop) => {
this.style.setProperty(prop, style[prop]);
});
Expand Down Expand Up @@ -245,28 +262,34 @@ export class ConfigTemplateCard extends LitElement {
svars: this._svarMgr?.svars ?? [], _evalInitSVars: this._svarMgr?._evalInitSVars ?? '',
vars: [], _evalInitVars: '',
};
const vars: Vars = (doStatic ? varMgr.svars : varMgr.vars);
const immediateVars: Vars = (doStatic ? varMgr.svars : varMgr.vars);
const initKey = (doStatic ? '_evalInitSVars' : '_evalInitVars');
const initRef = (doStatic ? 'svars' : 'vars');

const arrayVars2: any[] = [];
for (let v of arrayVars) {
if (isString(v)) {
v = this._evaluateTemplate(varMgr, v, true);
vars.push(v);
immediateVars.push(isPromise(v) ? null : v);
arrayVars2.push(v);
} else {
v = this._evaluateStructure(varMgr, v);
vars.push(v);
v = this._evaluateStructure(varMgr, v, 'both');
immediateVars.push(v.immediate);
arrayVars2.push(v.promise);
}
}
const namedVars2: ObjMap = {};
for (const varName in namedVars) {
let v = namedVars[varName];
if (isString(v)) {
v = this._evaluateTemplate(varMgr, v, true);
vars[varName] = v;
immediateVars[varName] = (isPromise(v) ? null : v);
namedVars2[varName] = v;
}
else {
v = this._evaluateStructure(varMgr, v);
vars[varName] = v;
v = this._evaluateStructure(varMgr, v, 'both');
immediateVars[varName] = v.immediate;
namedVars2[varName] = v.promise;
}
// Note that if `staticVariables` and `variables` both contain a variable with the same name
// then `_evalInitSVars + _evalInitVars` will end up defining the variable twice. This
Expand All @@ -276,35 +299,108 @@ export class ConfigTemplateCard extends LitElement {
varMgr[initKey] += `var ${varName} = ${initRef}['${varName}'];\n`;
}

let promiseArrayVars: Promise<any[]> | undefined;
if (somePromise(arrayVars2)) {
promiseArrayVars = Promise.all(arrayVars2.map((v) => Promise.resolve(v)));
}
let promiseNamedVars: Promise<ObjMap> | undefined;
if (somePromise(Object.entries(namedVars2).map(([_k, v], _i) => v))) {
promiseNamedVars = Promise.all(Object.entries(namedVars2).map(([k, v], _i) =>
Promise.resolve(v).then((v) => [k, v])
)).then((a) => Object.fromEntries(a));
}
let promise: Promise<any> | undefined;
if (isPromise(promiseArrayVars) || isPromise(promiseNamedVars)) {
promise = Promise.all([promiseArrayVars, promiseNamedVars].map((v) => Promise.resolve(v)))
.then(([pav, pnv]) => Object.assign(pav as any[], pnv as ObjMap));
}

if (doStatic) {
const svarMgr: SVarMgr = {
svars: vars, _evalInitSVars: varMgr._evalInitSVars,
svars: immediateVars, _evalInitSVars: varMgr._evalInitSVars,
};
if (promise) {
svarMgr._svarsPromise = promise.then((v) => {
svarMgr.svars = v;
svarMgr._svarsPromise = undefined;
return v;
});
}
return svarMgr;
} else {
if (promise) {
varMgr._varsPromise = promise.then((v) => {
varMgr.vars = v;
varMgr._varsPromise = undefined;
return v;
});
}
return varMgr;
}
}

private _evaluateStructure(varMgr: VarMgr, struct: any): any {
// Return value is based on `mode`:
// * 'promise' will return either a Promise (that will settle when all Promises returned by
// templates have settled), or a non-Promise (if no nested templates return a Promise).
// * 'immediate' will return a non-Promise with all Promise values returned by templates replaced
// with `null`.
// * 'both' will return both of the above as `{ promise: p, immediate: i }`.
private _evaluateStructure(varMgr: VarMgr, struct: any, mode = 'promise'): any {
let ret;

if (struct instanceof Array) {
ret = struct.map((v, _i) =>
this._evaluateStructure(varMgr, v)
this._evaluateStructure(varMgr, v, mode)
);
if (mode != 'immediate') {
let promiseTmp: any[], immediate: any[] = [], promise: any[] | Promise<any[]>;
if (mode == 'both') { immediate = ret.map((v) => v.immediate); }
promise = promiseTmp = (mode == 'both' ? ret.map((v) => v.promise) : ret);
if (somePromise(promiseTmp)) {
promise = Promise.all(promiseTmp.map((v) => Promise.resolve(v)));
}
ret = (mode == 'both' ? { promise: promise, immediate: immediate } : promise);
}

} else if (typeof struct === 'object') {
const tmp = Object.entries(struct as ObjMap).map(([k, v], _i) =>
[k, this._evaluateStructure(varMgr, v)]
[k, this._evaluateStructure(varMgr, v, mode)]
);
ret = Object.fromEntries(tmp);
let immediateTmp: any[], promiseTmp: any[];
let immediate: ObjMap = {}, promise: ObjMap | Promise<ObjMap> = {};
if (mode != 'promise') {
immediateTmp = (mode == 'both' ? tmp.map(([k, v], _i) => [k, v.immediate]) : tmp);
immediate = Object.fromEntries(immediateTmp);
ret = immediate;
}
if (mode != 'immediate') {
promiseTmp = (mode == 'both' ? tmp.map(([k, v], _i) => [k, v.promise]) : tmp);
if (somePromise(promiseTmp.map(([_k, v], _i) => v))) {
promise = Promise.all(promiseTmp.map(([k, v], _i) =>
Promise.resolve(v).then((v) => [k, v])
)).then((a) => Object.fromEntries(a));
} else {
promise = Object.fromEntries(promiseTmp);
}
ret = promise;
}
if (mode == 'both') { ret = { promise: promise, immediate: immediate }; }

} else if (isString(struct)) {
ret = this._evaluateTemplate(varMgr, struct);
if (isPromise(ret)) {
if (mode == 'immediate') {
console.warn("Ignoring asynchronous function in 'entities'. Asynchronous functions are not permitted in 'entities'.");
ret = null;
}
if (mode == 'both') { ret = { promise: ret, immediate: null }; }
} else {
if (mode == 'both') { ret = { promise: ret, immediate: ret }; }
}

} else {
ret = structuredClone(struct);
if (mode == 'both') { ret = { promise: ret, immediate: ret }; };
}

return ret;
Expand All @@ -326,8 +422,18 @@ export class ConfigTemplateCard extends LitElement {
const repls = matches.map((m, _i) => {
return [m, this._evalWithVars(varMgr, m.substring(2, m.length - 1), '<error>')]
});
repls.forEach(([m, r]) => template = template.replace(m as string, r.toString() as string));
return template;
if (somePromise(repls.map(([_m, r], _i) => r))) {
return Promise.all(repls.map(([m, p]) =>
Promise.resolve(p).then((r) => [m, r])
)).then((a) => {
let t = template;
a.forEach(([m, r]) => t = t.replace(m as string, r.toString() as string));
return t;
});
} else {
repls.forEach(([m, r]) => template = template.replace(m as string, r.toString() as string));
return template;
}
}

if (withoutDelim) {
Expand Down Expand Up @@ -380,6 +486,12 @@ export class ConfigTemplateCard extends LitElement {

const ret = indirectEval(initBase + initSVars + initVars + template);

if (isPromise(ret)) {
return ret.catch((e: unknown) => {
console.error('Template error:', e);
return exceptRet;
});
}
return ret;
} catch(e) {
console.error('Template error:', e);
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface Config {
export interface SVarMgr {
svars: Vars;
_evalInitSVars: string;
_svarsPromise?: Promise<any>;
}

export interface VarMgr {
Expand All @@ -33,4 +34,5 @@ export interface VarMgr {
_evalInitSVars: string;
vars: Vars;
_evalInitVars: string;
_varsPromise?: Promise<any>;
}

0 comments on commit e812ef3

Please sign in to comment.