diff --git a/examples/sensors.md b/examples/sensors.md new file mode 100644 index 0000000..863cf53 --- /dev/null +++ b/examples/sensors.md @@ -0,0 +1,143 @@ +# Different example usage of sensors with templating support (2.4 release) + +### Render a state value from a different entity: + +This is the basic, most used case. Just render the state of a different sensor. +All that happens when you don't pass a template is that a default template is used for label + value. +The two following sensors are thus equal + +```yaml +type: 'custom:simple-thermostat' +entity: climate.living_room +sensors: + - entity: sensor.living_room_humidity + + - entity: sensor.living_room_humidity + template: '{{state.text}}' + label: '{{friendly_name}}' +``` + +### Override default state/temperature sensors + +By default you get two sensors that are built-in, which you can override by including a sensor with an `id` set to one of the following. +Note that this shows the default configurations, so doing this means you get the built-in result, you just moved the definition away from the defaults logic to your own config. +But you can use this to tweak it. + +```yaml +type: 'custom:simple-thermostat' +entity: climate.living_room +sensors: + - id: state + label: '{{ui.operation}}' + template: '{{state.text}}' + - id: temperature + label: '{{ui.currently}}' + template: '{{current_temperature|formatNumber}}' +``` + +You need to filter current_temperature through `formatNumber` to get a number that respects your config for `decimals`. In the same way, you can use `formatNumber` on any numeric value to show it using the desired decimals. + +The `ui.operation` value looks strange, but we'll get back to what the `ui` variable represents in the translations section. + +### Render attributes from the main climate entity with templates + +> You can use `state` + all attributes from the entity in your template. + +```yaml +type: 'custom:simple-thermostat' +entity: climate.living_room +sensors: + - label: Min/max temp + template: '{{min_temp}} / {{max_temp}}' +``` + +Templating with lists of values, use `filters` to prepare it to a string: + +```yaml +type: 'custom:simple-thermostat' +entity: climate.living_room +sensors: + - label: Supported HVAC modes + template: "{{hvac_modes|join(', ')}}" +``` + +### Use a different entity as context + +All the attributes from the entity referenced can be reached as variables in the template. + +```yaml +type: 'custom:simple-thermostat' +entity: climate.living_room +sensors: + - label: Temperature + entity: sensor.multisensor_living_room + template: '{{temperature}} {{unit_of_measurement}}' +``` + +### Pass custom variables + +You can also pass an object with variables so you don't have to keep long strings in templates. +This also showcases how you can render a dynamic icon based on a value. +Lets replace the built-in `State` with an icon + +```yaml +type: 'custom:simple-thermostat' +entity: climate.living_room +variables: + icons: + idle: 'mdi:sleep' + heat: 'mdi:radiator' +sensors: + - label: State + id: state + template: '{{v.icons[state.raw]|icon}}' +``` + +Wows, now that seems awfully complex. +To break it down. You can render an icon with `{{"mdi:sleep"|icon}}`, and the `variables` config is made accessible under `v`. So we look up the icon matching `state.raw`, then finally we pass it to a _filter_ named `icon`. The `icon` filter will make sure the passed value is shown as an icon. You can pass `mdi:` and `hass:` to it. + +### Available filters + +| Name | Description | Example | +| ------------ | ----------------------------------------------- | -------------- | --------------------------------------------- | +| icon | Render as icon | `{{"mdi:sleep" | icon}}` | +| translate | Use HA translation string | `{{"on" | translate("state.default.")}}` | +| formatNumber | Format a number with x decimals | `{{3 | formatNumber({ decimals: 3 }) }}` | +| css | (For the crazy ones). Set custom css properties | `{{state.text | css({ 'font-size': '3em', color: 'red' }) }}` | +| debug | Print a structure as a JSON string | `{{state | debug}}` | + +### Translations + +You can look up translated strings from all the UI translation strings HA uses. Its over a thousand strings so we will not list them all, but if you know about your string you can reach it like this in your template: + +`{{"on"|translate("state.default.")}}` + +This will match the `on` string under the prefix `state.default.`, so resulting in a translation string with the key `state.default.on`. +The reason its split in a string + a prefix is that while this string were typed out you often have a dynamic string under a fixed prefix. + +**The ui object** + +You can reach all the translations for the HA native climate card under `ui.` as a shorthand. +The full list of available translations as of writing this are: + +| Key | Value | +| ------------------------------ | ---------------------------------- | +| `ui.currently` | `Currently` | +| `ui.on_off` | `On / off` | +| `ui.target_temperature` | `Target temperature` | +| `ui.target_temperature_entity` | `{name} target temperature` | +| `ui.target_temperature_mode` | `{name} target temperature {mode}` | +| `ui.current_temperature` | `{name} current temperature` | +| `ui.heating` | `{name} heating` | +| `ui.cooling` | `{name} cooling` | +| `ui.high` | `high` | +| `ui.low` | `low` | +| `ui.target_humidity` | `Target humidity` | +| `ui.operation` | `Operation` | +| `ui.fan_mode` | `Fan mode` | +| `ui.swing_mode` | `Swing mode` | +| `ui.preset_mode` | `Preset` | +| `ui.away_mode` | `Away mode` | +| `ui.aux_heat` | `Aux heat` | + +At the moment the {name} and {mode} in some strings are not interpolated diff --git a/package.json b/package.json index a97d1c2..cfbf246 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ ], "dependencies": { "debounce-fn": "^5.0.0", - "lit-element": "^2.4.0" + "lit-element": "^2.4.0", + "squirrelly": "^8.0.8" }, "devDependencies": { "@rollup/plugin-commonjs": "^17.0.0", @@ -76,7 +77,7 @@ "size-limit": [ { "path": "dist/simple-thermostat.js", - "limit": "15 KB" + "limit": "20 KB" } ] } diff --git a/rollup.config.js b/rollup.config.js index e036a5f..fb3f067 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -11,7 +11,9 @@ import dts from 'rollup-plugin-dts' import inject from 'rollup-plugin-inject-process-env' const shared = (DEBUG) => [ - resolve(), + resolve({ + browser: true, + }), commonjs(), json(), inject( diff --git a/src/components/sensors.ts b/src/components/sensors.ts index 0b1185b..4930df0 100644 --- a/src/components/sensors.ts +++ b/src/components/sensors.ts @@ -1,6 +1,7 @@ import { html } from 'lit-html' import formatNumber from '../formatNumber' import renderInfoItem from './infoItem' +import { wrapSensors } from './templated' export default function renderSensors({ _hide, @@ -17,10 +18,7 @@ export default function renderSensors({ attributes: { hvac_action: action, current_temperature: current }, } = entity - const { type, labels: showLabels } = config?.layout?.sensors ?? { - type: 'table', - labels: true, - } + const showLabels = config?.layout?.sensors?.labels ?? true let stateString = localize(state, 'component.climate.state._.') if (action) { stateString = [ @@ -64,9 +62,5 @@ export default function renderSensors({ }) || null), ].filter((it) => it !== null) - const classes = [ - showLabels ? 'with-labels' : 'without-labels', - type === 'list' ? 'as-list' : 'as-table', - ] - return html`
${sensorHtml}
` + return wrapSensors(config, sensorHtml) } diff --git a/src/components/templated.ts b/src/components/templated.ts new file mode 100644 index 0000000..3ee576d --- /dev/null +++ b/src/components/templated.ts @@ -0,0 +1,106 @@ +import * as Sqrl from 'squirrelly' +import { html } from 'lit-html' +import { unsafeHTML } from 'lit-html/directives/unsafe-html.js' +import formatNumber from '../formatNumber' +import renderInfoItem from './infoItem' + +const renderIcon = (icon) => `` + +Sqrl.defaultConfig.autoEscape = false // Turns autoEscaping on +Sqrl.filters.define('icon', renderIcon) +Sqrl.filters.define('join', (arr, delimiter = ', ') => arr.join(delimiter)) +Sqrl.filters.define('css', (str, css) => { + const styles = Object.entries(css).reduce((memo, [key, val]) => { + return `${memo}${key}:${val};` + }, '') + return `${str}` +}) + +Sqrl.filters.define('debug', (data) => { + try { + return JSON.stringify(data) + } catch { + return `Not able to read valid JSON object from: ${data}` + } +}) + +export function wrapSensors(config, content) { + const { type, labels: showLabels } = config?.layout?.sensors ?? { + type: 'table', + labels: true, + } + + const classes = [ + showLabels ? 'with-labels' : 'without-labels', + type === 'list' ? 'as-list' : 'as-table', + ] + return html`
${content}
` +} + +export default function renderTemplated({ + context, + entityId, + template = '{{state.text}}', + label, + hass, + variables = {}, + config, + localize, + openEntityPopover, +}) { + const { state, attributes } = context + + const [domain] = entityId.split('.') + const lang = hass.selectedLanguage || hass.language + const trPrefix = 'ui.card.climate.' + const translations = Object.entries(hass.resources[lang]).reduce( + (memo, [key, value]) => { + if (key.startsWith(trPrefix)) memo[key.replace(trPrefix, '')] = value + return memo + }, + {} + ) + + // Prepare data to inject as variables into the template + const data = { + ...attributes, + state: { + raw: state, + text: localize(state, `component.${domain}.state._.`), + }, + ui: translations, + v: variables, + } + + // Need to define these inside the function to be able to reach local scope + Sqrl.filters.define( + 'formatNumber', + (str, opts = { decimals: config.decimals }) => { + return String(formatNumber(str, opts)) + } + ) + Sqrl.filters.define('translate', (str, prefix = '') => { + if (!prefix && (domain === 'climate' || domain === 'humidifier')) { + return localize(str, `state_attributes.${domain}.${str}`) + } + return localize(str, prefix) + }) + + const render = (template) => Sqrl.render(template, data, { useWith: true }) + + const value = render(template) + + if (label === false || config?.layout?.sensors?.labels === false) { + return html`
${unsafeHTML(value)}
` + } + + const safeLabel = label || '{{friendly_name}}' + const heading = safeLabel.match(/^(mdi|hass):.*/) + ? renderIcon(safeLabel) + : render(safeLabel) + + return html` +
${unsafeHTML(heading)}
+
${unsafeHTML(value)}
+ ` +} diff --git a/src/config/card.ts b/src/config/card.ts index 0510cd0..0a85c73 100644 --- a/src/config/card.ts +++ b/src/config/card.ts @@ -1,5 +1,5 @@ import { HeaderConfig } from './header' -import { LooseObject, ConfigSensor } from '../types' +import { LooseObject, ConfigSensor, TemplatedSensor } from '../types' import { Service } from './service' import { Setpoints } from './setpoints' @@ -44,10 +44,12 @@ interface CardConfig { entity?: string header: false | HeaderConfig control?: false | ModeControl | string[] - sensors?: false | Array + sensors?: false | Array + version: 2 | 3 setpoints?: Setpoints decimals?: number step_size?: number + variables?: LooseObject layout?: { mode: { names: boolean diff --git a/src/main.ts b/src/main.ts index 39eef8b..b99ba13 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import styles from './styles.css' import formatNumber from './formatNumber' import fireEvent from './fireEvent' import renderHeader from './components/header' +import renderTemplated, { wrapSensors } from './components/templated' import renderSensors from './components/sensors' import renderModeType from './components/modeType' @@ -23,6 +24,7 @@ import { ControlModeOption, LooseObject, Sensor, + PreparedSensor, HASS, HVAC_MODES, } from './types' @@ -112,7 +114,7 @@ export default class SimpleThermostat extends LitElement { @property() entity: LooseObject @property() - sensors: Array = [] + sensors: Array = [] @property() showSensors: boolean = true @property() @@ -248,6 +250,43 @@ export default class SimpleThermostat extends LitElement { if (this.config.sensors === false) { this.showSensors = false + } else if (this.config.version === 3) { + this.sensors = [] + const customSensors = this.config.sensors.map((sensor, index) => { + const entityId = sensor?.entity ?? this.config.entity + let context = this.entity + if (sensor?.entity) { + context = this._hass.states[sensor.entity] + } + return { + id: sensor?.id ?? String(index), + label: sensor?.label, + template: sensor.template, + entityId, + context, + } as PreparedSensor + }) + const ids = customSensors.map((s) => s.id) + const builtins = [] + if (!ids.includes('state')) { + builtins.push({ + id: 'state', + label: '{{ui.operation}}', + template: '{{state.text}}', + entityId: this.config.entity, + context: this.entity, + }) + } + if (!ids.includes('temperature')) { + builtins.push({ + id: 'temperature', + label: '{{ui.currently}}', + template: '{{current_temperature|formatNumber}}', + entityId: this.config.entity, + context: this.entity, + }) + } + this.sensors = [...builtins, ...customSensors] } else if (this.config.sensors) { this.sensors = this.config.sensors.map( ({ name, entity, attribute, unit = '', ...rest }) => { @@ -317,6 +356,34 @@ export default class SimpleThermostat extends LitElement { const row = stepLayout === 'row' const classes = [!this.header && 'no-header', action].filter((cx) => !!cx) + + let sensorsHtml + if (this.config.version === 3) { + sensorsHtml = this.sensors.map((spec: PreparedSensor) => { + return renderTemplated({ + ...spec, + variables: this.config.variables, + hass: this._hass, + config: this.config, + localize: this.localize, + openEntityPopover: this.openEntityPopover, + }) + }) + sensorsHtml = wrapSensors(this.config, sensorsHtml) + } else { + sensorsHtml = this.showSensors + ? renderSensors({ + _hide: this._hide, + unit, + hass: this._hass, + entity: this.entity, + sensors: this.sensors, + config: this.config, + localize: this.localize, + openEntityPopover: this.openEntityPopover, + }) + : '' + } return html` ${warnings} @@ -327,18 +394,7 @@ export default class SimpleThermostat extends LitElement { openEntityPopover: this.openEntityPopover, })}
- ${this.showSensors - ? renderSensors({ - _hide: this._hide, - unit, - hass: this._hass, - entity: this.entity, - sensors: this.sensors, - config: this.config, - localize: this.localize, - openEntityPopover: this.openEntityPopover, - }) - : ''} + ${sensorsHtml} ${Object.entries(_values).map(([field, value]) => { const hasValue = ['string', 'number'].includes(typeof value) const showUnit = unit !== false && hasValue diff --git a/src/types.ts b/src/types.ts index 8423d10..b1bffb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,11 +4,28 @@ export type LooseObject = Record export interface ConfigSensor { entity: string + id?: string name?: string icon?: string attribute?: string unit?: string decimals?: number + template?: string + type?: 'relativetime' | 'template' +} + +export interface TemplatedSensor { + template: string + label?: string | false + entity?: string +} + +export interface PreparedSensor { + id: string + label: string | false + entityId: string + template: string + context: LooseObject } export interface Sensor extends ConfigSensor { diff --git a/yarn.lock b/yarn.lock index a0c2f39..e1e9e49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8908,6 +8908,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +squirrelly@^8.0.8: + version "8.0.8" + resolved "https://registry.yarnpkg.com/squirrelly/-/squirrelly-8.0.8.tgz#d6704650b2170b8040d5de5bff9fa69cb62b5e0f" + integrity sha512-7dyZJ9Gw86MmH0dYLiESsjGOTj6KG8IWToTaqBuB6LwPI+hyNb6mbQaZwrfnAQ4cMDnSWMUvX/zAYDLTSWLk/w== + sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"