Compare commits

...

9 Commits

Author SHA1 Message Date
Petar Petrov
5a4080f866 use isolated env 2025-08-27 10:17:26 +03:00
Petar Petrov
664ace6644 store rendered value 2025-08-27 09:59:50 +03:00
Petar Petrov
bd89553008 remove from tile and add to gallery 2025-08-27 09:12:45 +03:00
Petar Petrov
64993ff409 fix shouldUpdate 2025-08-27 08:33:04 +03:00
Petar Petrov
273aa3db5c HaTemplate non element class to use render to string 2025-08-26 18:44:52 +03:00
Petar Petrov
75ff4a8c04 Use Set to avoid duplicates 2025-08-26 10:55:06 +03:00
Petar Petrov
8b523a5aaa state_attr 2025-08-26 10:46:48 +03:00
Petar Petrov
5261d7bb7b clean up 2025-08-26 10:17:05 +03:00
Petar Petrov
024c70c49e Allow templates in tile card name 2025-08-26 10:05:43 +03:00
6 changed files with 295 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
---
title: Template
---

View File

@@ -0,0 +1,65 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-template";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { getEntity } from "../../../../src/fake_data/entity";
interface TemplateContent {
content: string;
}
const templates: TemplateContent[] = [
{ content: "{{ states('sensor.temperature') }}" },
{
content: "{{ 'Day' if is_state('sun.sun', 'above_horizon') else 'Night' }}",
},
];
const ENTITIES = [
getEntity("sensor", "temperature", "25", {
friendly_name: "Temperature",
}),
getEntity("sun", "sun", "above_horizon", {
friendly_name: "Controller 2",
}),
];
@customElement("demo-misc-ha-template")
export class DemoMiscTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
protected firstUpdated() {
const hass = provideHass(this);
hass.addEntities(ENTITIES);
}
protected render() {
return html`
<div class="container">
${templates.map(
(t) =>
html`<ha-card>
<pre>Template: ${t.content}</pre>
<pre>Result: <ha-template
.hass=${this.hass} .content=${t.content}></ha-template></pre>
</ha-card>`
)}
</div>
`;
}
static styles = css`
ha-card {
margin: 12px;
padding: 12px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-ha-template": DemoMiscTemplate;
}
}

View File

@@ -126,6 +126,7 @@
"marked": "16.2.0",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"nunjucks": "3.2.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
@@ -174,6 +175,7 @@
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/mocha": "10.0.10",
"@types/nunjucks": "^3",
"@types/qrcode": "1.5.5",
"@types/sortablejs": "1.15.8",
"@types/tar": "6.1.13",

137
src/common/template.ts Normal file
View File

@@ -0,0 +1,137 @@
import nunjucks, { Environment, Template as NunjucksTemplate } from "nunjucks";
import type { HomeAssistant } from "../types";
nunjucks.installJinjaCompat();
function createEnv() {
const env = new Environment();
// Add filters and globals that don't use hass
env.addFilter("min", (numbers: number[]) => Math.min(...numbers));
env.addFilter("max", (numbers: number[]) => Math.max(...numbers));
env.addGlobal(
"states",
(
hass: HomeAssistant,
id: string,
round = false,
withUnit = false
): string => {
if (!hass?.states[id]) {
return "unknown";
}
const state = hass?.states[id]?.state;
if (state == null) {
return "unavailable";
}
if (round) {
return String(Math.round(Number(state)));
}
if (withUnit) {
return `${state} ${hass?.states[id]?.attributes.unit_of_measurement}`;
}
return state;
}
);
env.addGlobal(
"state_attr",
(hass: HomeAssistant, id: string, attr: string) =>
hass?.states[id]?.attributes[attr]
);
env.addGlobal(
"is_state",
(hass: HomeAssistant, id: string, value: string) =>
hass?.states[id]?.state === value
);
env.addGlobal(
"is_state_attr",
(hass: HomeAssistant, id: string, attr: string, value: string) =>
hass?.states[id]?.attributes[attr] === value
);
env.addGlobal(
"has_value",
(hass: HomeAssistant, id: string) => hass?.states[id]?.state != null
);
env.addGlobal("state_translated", (hass: HomeAssistant, id: string) => {
try {
return hass?.formatEntityState(hass?.states[id], hass?.states[id]?.state);
} catch {
return hass?.states[id]?.state ?? undefined;
}
});
return env;
}
export class HaTemplate {
private _njTemplate?: NunjucksTemplate;
private _hass?: HomeAssistant;
private _content?: string;
private _context?: Record<string, any>;
private _value = "";
public entityIds = new Set<string>();
public shouldUpdate = false;
private _env = createEnv();
constructor() {
// functions that access the hass state have to be dynamic
// in order to track which entities are used in the template
[
"states",
"state_attr",
"is_state",
"is_state_attr",
"has_value",
"state_translated",
].forEach((func) => {
const original = this._env.getGlobal(func);
this._env.addGlobal(func, (id: string, ...args: any[]): string => {
this.entityIds.add(id);
return original(this._hass, id, ...args);
});
});
}
public render(): string {
if (this.shouldUpdate) {
this.shouldUpdate = false;
this.entityIds.clear();
this._value = this._njTemplate!.render(this._context);
}
return this._value;
}
public set content(content: string) {
if (this._content !== content) {
this._content = content;
this._njTemplate = new NunjucksTemplate(content, this._env);
this.shouldUpdate = true;
}
}
public set context(context: Record<string, any>) {
if (this._context !== context) {
this._context = context;
this.shouldUpdate = true;
}
}
public set hass(hass: HomeAssistant) {
if (this._hass !== hass) {
if (!this.shouldUpdate) {
this.shouldUpdate =
!this._hass !== !hass ||
Array.from(this.entityIds).some(
(id) => this._hass?.states[id]?.state !== hass.states[id]?.state
);
}
this._hass = hass;
}
}
}

View File

@@ -0,0 +1,40 @@
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { HaTemplate } from "../common/template";
@customElement("ha-template")
export class HaTemplateElement extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public content!: string;
@property({ attribute: false }) public context: Record<string, any> = {};
private _template = new HaTemplate();
protected shouldUpdate(): boolean {
if (this.hass) {
this._template.hass = this.hass;
this._template.content = this.content;
this._template.context = this.context;
}
return this._template.shouldUpdate;
}
public render() {
try {
return this._template.render();
} catch (error) {
// eslint-disable-next-line no-console
console.debug(`Error rendering template: ${error}`);
return this.content;
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-template": HaTemplateElement;
}
}

View File

@@ -4802,6 +4802,13 @@ __metadata:
languageName: node
linkType: hard
"@types/nunjucks@npm:^3":
version: 3.2.6
resolution: "@types/nunjucks@npm:3.2.6"
checksum: 10/6b77fd2e5e3a63f82149779ae83e6ade7f70779c63b9bdab9fdd52c388db4b442c8b5993f62917a9564fc5e5690784ea5558513c629bee5b8cf6b8e6d8eeac38
languageName: node
linkType: hard
"@types/offscreencanvas@npm:^2019.6.4":
version: 2019.7.3
resolution: "@types/offscreencanvas@npm:2019.7.3"
@@ -5554,6 +5561,13 @@ __metadata:
languageName: node
linkType: hard
"a-sync-waterfall@npm:^1.0.0":
version: 1.0.1
resolution: "a-sync-waterfall@npm:1.0.1"
checksum: 10/6069080aff936c88fc32f798cc172a8b541e35b993dc5d2e43b74b6f37c522744eec107e1d475d2c624825c6cb7d2ec9ec020dbe4520578afcae74f11902daa2
languageName: node
linkType: hard
"abbrev@npm:^3.0.0":
version: 3.0.1
resolution: "abbrev@npm:3.0.1"
@@ -5968,6 +5982,13 @@ __metadata:
languageName: node
linkType: hard
"asap@npm:^2.0.3":
version: 2.0.6
resolution: "asap@npm:2.0.6"
checksum: 10/b244c0458c571945e4b3be0b14eb001bea5596f9868cc50cc711dc03d58a7e953517d3f0dad81ccde3ff37d1f074701fa76a6f07d41aaa992d7204a37b915dda
languageName: node
linkType: hard
"assertion-error@npm:^2.0.1":
version: 2.0.1
resolution: "assertion-error@npm:2.0.1"
@@ -6818,6 +6839,13 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:^5.1.0":
version: 5.1.0
resolution: "commander@npm:5.1.0"
checksum: 10/3e2ef5c003c5179250161e42ce6d48e0e69a54af970c65b7f985c70095240c260fd647453efd4c2c5a31b30ce468f373dc70f769c2f54a2c014abc4792aaca28
languageName: node
linkType: hard
"common-tags@npm:^1.8.0":
version: 1.8.2
resolution: "common-tags@npm:1.8.2"
@@ -9397,6 +9425,7 @@ __metadata:
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.1"
"@types/mocha": "npm:10.0.10"
"@types/nunjucks": "npm:^3"
"@types/qrcode": "npm:1.5.5"
"@types/sortablejs": "npm:1.15.8"
"@types/tar": "npm:6.1.13"
@@ -9469,6 +9498,7 @@ __metadata:
marked: "npm:16.2.0"
memoize-one: "npm:6.0.0"
node-vibrant: "npm:4.0.3"
nunjucks: "npm:3.2.4"
object-hash: "npm:3.0.0"
pinst: "npm:3.0.0"
prettier: "npm:3.6.2"
@@ -11653,6 +11683,24 @@ __metadata:
languageName: node
linkType: hard
"nunjucks@npm:3.2.4":
version: 3.2.4
resolution: "nunjucks@npm:3.2.4"
dependencies:
a-sync-waterfall: "npm:^1.0.0"
asap: "npm:^2.0.3"
commander: "npm:^5.1.0"
peerDependencies:
chokidar: ^3.3.0
peerDependenciesMeta:
chokidar:
optional: true
bin:
nunjucks-precompile: bin/precompile
checksum: 10/8decb8bb762501aa1a44366acff50ab9d4ff9e57034455e62056b4ac117da40140e1f34f2270c38884f1a5b84b7d97c4afcb2e8c789ddd09f4dcfe71ce7b56bf
languageName: node
linkType: hard
"nwsapi@npm:^2.2.16":
version: 2.2.21
resolution: "nwsapi@npm:2.2.21"