Skip to content

Commit

Permalink
feat: Template based sensors
Browse files Browse the repository at this point in the history
Far more flexible sensor definitions using templating

- Replace all sensor logic with templates.
- Allow overriding builtins (state, current temperature)
- New sensor properties: `template`, `label`, `entity`. Only `template`
  is required.

Fixes #192, #228
Ref #155
  • Loading branch information
nervetattoo committed Apr 15, 2021
1 parent 1f9278e commit 7070f02
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 27 deletions.
143 changes: 143 additions & 0 deletions examples/sensors.md
Original file line number Diff line number Diff line change
@@ -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:<name>` and `hass:<name>` 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.<key>` 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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -76,7 +77,7 @@
"size-limit": [
{
"path": "dist/simple-thermostat.js",
"limit": "15 KB"
"limit": "20 KB"
}
]
}
4 changes: 3 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 3 additions & 9 deletions src/components/sensors.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = [
Expand Down Expand Up @@ -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` <div class="sensors ${classes.join(' ')}">${sensorHtml}</div> `
return wrapSensors(config, sensorHtml)
}
106 changes: 106 additions & 0 deletions src/components/templated.ts
Original file line number Diff line number Diff line change
@@ -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) => `<ha-icon icon="${icon}"></ha-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 `<span style="${styles}">${str}</span>`
})

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` <div class="sensors ${classes.join(' ')}">${content}</div> `
}

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`<div class="sensor-value">${unsafeHTML(value)}</div>`
}

const safeLabel = label || '{{friendly_name}}'
const heading = safeLabel.match(/^(mdi|hass):.*/)
? renderIcon(safeLabel)
: render(safeLabel)

return html`
<div class="sensor-heading">${unsafeHTML(heading)}</div>
<div class="sensor-value">${unsafeHTML(value)}</div>
`
}
6 changes: 4 additions & 2 deletions src/config/card.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -44,10 +44,12 @@ interface CardConfig {
entity?: string
header: false | HeaderConfig
control?: false | ModeControl | string[]
sensors?: false | Array<ConfigSensor>
sensors?: false | Array<ConfigSensor & TemplatedSensor>
version: 2 | 3
setpoints?: Setpoints
decimals?: number
step_size?: number
variables?: LooseObject
layout?: {
mode: {
names: boolean
Expand Down
Loading

0 comments on commit 7070f02

Please sign in to comment.