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