HassForge - Code-first Home Assistant templating

Hi guys,

Sharing this project I’ve been working on for some time now:

HassForge
Built on-top of Typescript, it uses strongly typed JSON and spits out Home Assistant YAML.

Current Status:
Currently Unreleased: I’m using this post to find out if people are actually interested.
Documentation is currently a WIP.

Why?
A few reasons:

  • I’m a developer
    • I want to leverage code to generate complex concepts (Loops, code-reuse, etc.)
    • I want to use version control systems as a way to backup and track changes
    • I want strong types, I shouldn’t need to edit code and use a UI to check if my configuration is correct.
  • Home Assistant basic entities are powerful, but complex dashboards will result in a lot of configuration
    • But I still want to leverage the basics! I shouldn’t need extra integrations to manage simple motion activated lights for example.

Ok, so what is HassForge?
In its current state, it’s a Typescript DSL plus a command line interface that will automatically generate Home Assistant YAML.
The end goal for HassForge right now is a Home Assistant integration that will act as a “CI/CD” pipeline using Git as its backbone, though this is still a ways off.

Here’s an example dashboard generated using HassForge:

Let’s just show you some code.
Take into consideration what this YAML would look like if you had to manage 10+ rooms and manage it entirely manually.

import {
  Room,
  GenericThermostatClimate,
  Dashboard,
  defineConfig,
} from "@hassforge/base";
import {
  MotionActivatedAutomation,
} from "@hassforge/recipes";
import { MushroomDashboard } from "@hassforge/mush-room";

const wardrobe = new Room("Wardrobe")
  .addLights({
    name: "Wardrobe Lights",
    id: "switch.wardrobe_lights",
  })
  .addBinarySensors({
    name: "Wardrobe Motion",
    id: "binary_sensor.ewelink_66666_iaszone",
    device_class: "motion",
  })
  .addClimates(
    {
      name: "Wardrobe TRV",
      id: "climate.tze200_6rdj8dzm_ts0601_thermostat_9",
    },
    new GenericThermostatClimate({
      name: "Main Bedroom Electric",
      heater: "switch.legrand_connected_outlet_switch_3",
      target_sensor: "sensor.tz3000_fllyghyj_ts0201_temperature",
    })
  )
  .addAutomations(
    new MotionActivatedAutomation({
      alias: "Wardrobe Motion Activated Lights",
      motionSensors: ["binary_sensor.ewelink_66666_iaszone"],
      switchEntities: ["switch.wardrobe_lights"],
      delayOff: {
        minutes: 15,
      },
    })
  )

export default defineConfig({
  rooms: {
    wardrobe,
  },
  dashboards: {
    home: new MushroomDashboard("Home").addRooms(
      wardrobe
    ),
  },
});

Let’s explain a little:

We have a mix of “backend” entities and “frontend entities”
Backend entities:

  • A new climate: “Main Bedroom Electric”,
  • A new automation: “Wardrobe Motion Activated Lights”

A Frontend Dashboard: This can be thought of similar to a “strategy” except it’s much more controllable,
The “home” dashboard takes in all of our rooms, and spits out a bunch of cards in YAML format once built using the CLI.

The Room Heating extension
A Room extension is a way to group functionality. Let’s take the Room heating as an example.

I want some snazzy front-end graphs.
I want some switches on my dashboard for each of the radiators
I want some extra sensors:

  • What’s the average temperature of the room?
  • What’s the desired temperature of each Radiator? (IE, is the radiator calling for heat?)
import { WithRoomHeating } from "@hassforge/room-heating";
const wardrobe = new Room("Wardrobe")
  // ... Room entities
  .extend(WithRoomHeating)

Generates the following card example, and backend sensors to go with it.

What does the generated YAML look like?

Frontend:

title: Heating
cards:
  - type: custom:vertical-stack-in-card
    title: Wardrobe
    cards:
      - type: horizontal-stack
        cards:
          - type: custom:mini-graph-card
            name: Wardrobe TRV
            line_width: 4
            font_size: 75
            points_per_hour: 15
            color_thresholds:
              - value: 10
                color: "#0284c7"
              - value: 15
                color: "#f39c12"
              - value: 20
                color: "#c0392b"
            entities:
              - entity: climate.tze200_6rdj8dzm_ts0601_thermostat_9
                attribute: current_temperature
              - entity: climate.tze200_6rdj8dzm_ts0601_thermostat_9
                color: white
                show_line: false
                show_points: false
                show_legend: false
                y_axis: secondary
            show:
              labels: false
      - type: custom:multiple-entity-row
        entity: climate.tze200_6rdj8dzm_ts0601_thermostat_9
        icon: mdi:fire
        name: Wardrobe TRV
        toggle: true
        state_header: On/Off
        entities:
          - name: Desired
            attribute: temperature
          - name: Current
            attribute: current_temperature

Backend:

automation:
  - alias: Wardrobe Motion Activated Lights
    trigger:
      - platform: state
        entity_id: binary_sensor.ewelink_66666_iaszone
        id: detected
        to: "on"
      - platform: state
        entity_id: binary_sensor.ewelink_66666_iaszone
        id: clear
        to: "off"
    condition: []
    action:
      - choose:
          - conditions:
              condition: trigger
              id: detected
            sequence:
              - service: switch.turn_on
                target:
                  entity_id: switch.wardrobe_lights
          - conditions:
              condition: trigger
              id: clear
            sequence:
              - delay:
                  minutes: 15
              - service: switch.turn_off
                target:
                  entity_id: switch.wardrobe_lights
    mode: restart
template:
  - sensor:
      - name: Desired Wardrobe Trv Temperature
        unique_id: desired_wardrobe_trv_temperature
        state: >2-
          
                      {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9', 'hvac_action') == "off" %}
                          0
                      {% else %}
                          {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9', 'temperature') | float }}
                      {% endif %}
        unit_of_measurement: °C
      - name: Average Wardrobe temperature
        unique_id: average_wardrobe_temperature
        state: >2
          
              {% set wardrobe_trv = state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9', 'current_temperature') %} 
              {% if is_number(wardrobe_trv) %}
                {{ (((wardrobe_trv | float)) / 1) | round(1, default=0) }}
              {% else %}
                Unknown
              {% endif %}
        unit_of_measurement: °C
homeassistant:
  customize:
    climate.tze200_6rdj8dzm_ts0601_thermostat_9:
      friendly_name: Wardrobe TRV
    sensor.desired_wardrobe_trv_temperature:
      friendly_name: Desired Wardrobe Trv Temperature
      unit_of_measurement: °C
    sensor.average_wardrobe_temperature:
      friendly_name: Average Wardrobe temperature
      unit_of_measurement: °C
    switch.wardrobe_lights:
      friendly_name: Wardrobe Lights

The WithSwitchControlledThermostat Extension.
My sitation:
I have a dumb boiler on a smart switch. When the switch is on, the boiler is burning and circulating hot water, when the switch is off, the boiler is off.

  • I want my boiler to turn on and off when radiators are calling for heat.
  • I want to know how long my boiler has been burning fuel for.
  • All of this for 10+ rooms and 18 Radiators.
const boilerRoom = new Room("Boiler Room").extend(
  WithSwitchControlledThermostat,
  {
    boilerOptions: {
      haSwitch: boilerSwitch,
      powerConsumptionSensor: boilerPowerConsumptionSensor,
      powerConsumptionStandbyRange: [130, 200],
    },
    rooms: [
      mainBedroom,
      wardrobe,
      endBedroom,
      spareBedroom,
      downstairsBathroom,
      lounge,
      kitchen,
      tomsOffice,
    ],
    includeClimate: (_, climate) =>
      climate.id.includes("ts0601") || climate.id.includes("sonoff_trvzb"),
  }
);

What does the above generate?

  • A sensor taking every climate provided and determining whether it needs heat
  • an Automation to automatically turn the boiler switch on and off depending on the rooms needing heat.
  • A sensor mapping out the boiler state: “Is the switch using above 200 watts? It’s burning fuel, is it using above 130? It’s in standby”
  • A frontend graph card for the above concepts.

Generated card:

Generated YAML:

automation:
  - alias: Turn off boiler when all rads satisfied
    id: turn_off_boiler_when_all_rads_satisfied
    trigger:
      - platform: state
        entity_id: sensor.radiators_requesting_heat
      - platform: time_pattern
        minutes: /15
    condition:
      - condition: template
        value_template: "{{ states('sensor.radiators_requesting_heat') | float == 0 }}"
      - condition: template
        value_template: >-
          {% set changed =
          as_timestamp(states.switch.legrand_connected_outlet_switch_4.last_changed)
          %}
                    {% set now = as_timestamp(now()) %}
                    {% set time = now - changed %}
                    {% set minutes = (time / 60) | int %}
                    {{ minutes > 5 }}
    action:
      - service: switch.turn_off
        target:
          entity_id: switch.legrand_connected_outlet_switch_4
  - alias: Turn on boiler when heat needed
    id: turn_on_boiler_when_heat_needed
    trigger:
      - platform: state
        entity_id: sensor.radiators_requesting_heat
      - platform: time_pattern
        minutes: /15
    condition:
      - condition: template
        value_template: "{{ states('sensor.radiators_requesting_heat') | float > 0 }}"
      - condition: template
        value_template: >-
          {% set changed =
          as_timestamp(states.switch.legrand_connected_outlet_switch_4.last_changed)
          %}
            {% set now = as_timestamp(now()) %}
            {% set time = now - changed %}
            {% set minutes = (time / 60) | int %}
            {{ minutes > 5 }}
    action:
      - service: switch.turn_on
        target:
          entity_id: switch.legrand_connected_outlet_switch_4
sensor:
  - platform: history_stats
    name: Boiler burning today
    entity_id: sensor.boiler_burning_state
    state: "on"
    type: time
    end: "{{ now() }}"
    duration:
      hours: 24
template:
  - sensor:
      - name: Boiler Burning State
        unique_id: boiler_burning_state
        state: >2-
          
                        {% if is_state('switch.legrand_connected_outlet_switch_4', 'off') %}
                          off
                        {% elif states('sensor.legrand_connected_outlet_active_power_4') | float < 130 %}
                          standby
                        {% elif states('sensor.legrand_connected_outlet_active_power_4') | float > 200 %}
                          on
                        {% else %}
                          failed
                        {% endif %}
      - name: Radiators Requesting Heat
        unique_id: radiators_requesting_heat
        state: "{{ [ 'sensor.main_bedroom_trv_heat_needed',
          'sensor.wardrobe_trv_heat_needed',
          'sensor.talis_bedroom_trv_heat_needed',
          'sensor.near_windows_trv_heat_needed',
          'sensor.corner_trv_heat_needed', 'sensor.music_room_trv_heat_needed',
          'sensor.kitchen_bifolds_trv_heat_needed',
          'sensor.kitchen_veranda_trv_heat_needed' ] | select('is_state',
          'True') | list | length }}"
      - name: Main bedroom trv Heat Needed
        unique_id: main_bedroom_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.sonoff_trvzb_thermostat', 'hvac_action') != "off" %}
                    {{ state_attr('climate.sonoff_trvzb_thermostat', 'current_temperature') < state_attr('climate.sonoff_trvzb_thermostat', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{ state_attr('climate.sonoff_trvzb_thermostat',
            'current_temperature') -
            state_attr('climate.sonoff_trvzb_thermostat', 'temperature') | float
            }}"
      - name: Wardrobe trv Heat Needed
        unique_id: wardrobe_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_9',
            'temperature') | float }}"
      - name: Talis bedroom trv Heat Needed
        unique_id: talis_bedroom_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_7', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_7', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_7', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_7',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_7',
            'temperature') | float }}"
      - name: Near windows trv Heat Needed
        unique_id: near_windows_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_2', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_2', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_2', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_2',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_2',
            'temperature') | float }}"
      - name: Corner trv Heat Needed
        unique_id: corner_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_5', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_5', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_5', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_5',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_5',
            'temperature') | float }}"
      - name: Music room trv Heat Needed
        unique_id: music_room_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_3', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_3', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_3', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_3',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_3',
            'temperature') | float }}"
      - name: Kitchen bifolds trv Heat Needed
        unique_id: kitchen_bifolds_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_6', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_6', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_6', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_6',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_6',
            'temperature') | float }}"
      - name: Kitchen veranda trv Heat Needed
        unique_id: kitchen_veranda_trv_heat_needed
        state: >2-
          
                  {% if state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_4', 'hvac_action') != "off" %}
                    {{ state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_4', 'current_temperature') < state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_4', 'temperature') }}
                  {% else %}
                    False
                  {% endif %}
        attributes:
          temperature_difference: "{{
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_4',
            'current_temperature') -
            state_attr('climate.tze200_6rdj8dzm_ts0601_thermostat_4',
            'temperature') | float }}"
homeassistant:
  customize:
    sensor.boiler_burning_today:
      friendly_name: Boiler burning today
    sensor.boiler_burning_state:
      friendly_name: Boiler Burning State
    sensor.radiators_requesting_heat:
      friendly_name: Radiators Requesting Heat
    sensor.main_bedroom_trv_heat_needed:
      friendly_name: Main bedroom trv Heat Needed
    sensor.wardrobe_trv_heat_needed:
      friendly_name: Wardrobe trv Heat Needed
    sensor.talis_bedroom_trv_heat_needed:
      friendly_name: Talis bedroom trv Heat Needed
    sensor.near_windows_trv_heat_needed:
      friendly_name: Near windows trv Heat Needed
    sensor.corner_trv_heat_needed:
      friendly_name: Corner trv Heat Needed
    sensor.music_room_trv_heat_needed:
      friendly_name: Music room trv Heat Needed
    sensor.kitchen_bifolds_trv_heat_needed:
      friendly_name: Kitchen bifolds trv Heat Needed
    sensor.kitchen_veranda_trv_heat_needed:
      friendly_name: Kitchen veranda trv Heat Needed

1 Like