Home Assistant to control per room valves on a schedule

Introduction

This post is about controlling individual room temperatures using Zigbee radiator valves from MOES. I previously was using Eurotronic Z-Wave valves for this but they proved to be unreliable and very battery hungry.

While it is quite straightforward to deploy a bunch of theses valves and add them to Home Assistant, creating a schedule is another story. Homeworking regularly, I also wanted the rooms to adopt a couple of behaviors:

  • Regular manual operation : Fully manual from the units themselves or from Home Assistant;
  • Fully automatic : Based on a week / weekend scheduler, overwriting manual commands;
  • Guests mode : Boosting specific rooms while having guests at home (kid’s room, entry hall…);
  • Homework : Boosting the office during working hours and kitchen for lunch;
  • Full OFF : For hot days or when using inverters;
  • Full power : Rarely used nowadays, set all rooms to max temperature.

I also recently added a toggle option for economic mode, limiting room temperatures to 20°c regardless of the scenario.

Spoiler : I haven’t been so far as implementing a PID controller, feel free to comment if you do.

Overview

So, what’s the plan?

As interesting as (I hope) this project can be, it requires some kind of inexpensive devices but this increases quickly with the number of radiators.

Assuming you have a running Home Assistant machine, on the hardware side you need:

 

For the software side, on top of Home Assistant you need:

With everything running, you can pair the valves and deploy them.

Note : To have a reliable mesh Zigbee network I recommend deploying a bunch of ZigBee Smart plugs.

Drafting the schedule

Planning your rooms and temperatures

This step is essential and while it looks simple, it’ll require some tuning to let radiators heat soon enough to have confortable temperatures.

I created a schedule for each of the core scenario (work week, week-end and guests) since they are very different. Homeworking for instance is a simple override of the work week.

My code handles a target temperature per room per slot of one hour. Working on a 24 hours calendar my table has each room as lines and each hour slice as columns.

It’s noteworthy to make it clear the temperatures defined here are manually crafted, so adjust accordingly. To make it a bit easier, I’ve highlighted the morning / lunch and evening hours as well as some coloring based on values range.

As nice as this schedule could ever be, it is worthless as-is in Home Assistant. I’ve used some lazy coder Join formulas in Google Sheets to convert the table to a JavaScript array. More about JavaScript later on.

Generating JavaScript code

The final JS code is created in three steps:

  1. Format each cell value to a pair (key being hour and value being the temperature);
  2. Concatenate all pairs to an array for the room;
  3. Concatenate rooms to a single line of JS code.

Step 1 : Generating the pairs

Preparing JS for each row by converting the raw values to some simple JS.

Formula generates JS as “h7”: 8 along with room and entity constants for later retrieval in the Node-RED function block.

Step 2 : Assembling the code for each row

Before having the final JS, I’m concatenating once again the values by putting together the pairs into a arrays.

The real magic of below snippet is to generate arrays from each prepared row.

var roomTemperaturesWeek = [
=IF(REGEXMATCH(AG8;"var room");" ";",")&D18&join(",";E18:AC18)&"}"
=IF(REGEXMATCH(AG9;"var room");" ";",")&D19&join(",";E19:AC19)&"}"
...
=IF(REGEXMATCH(AG14;"var room");" ";",")&D24&join(",";E24:AC24)&"}"
];

Step 3 : Create the final JS code

Following the same lazy concatenations logic, convert each table to a single line of JS.

As step 2 prepared the work this one is very simple, converting multiple lines of JS to a single one.

Note about multiple scenarios and easing loops in Node-RED.

On top of the temperatures definition arrays I also assemble two more arrays : One for rooms and one for entities.

Node-RED magic

Okay, that’s not magic but it could be.

In Node-RED I’ve dedicated a flow for everything related to radiators valves and heater.

The base logic is very simple, it however requires careful planning to not end up with a useless mess of wires and nodes.

So, how does it work? An overview

  1. We need a trigger to update valves every now and then : That’s a “Inject” node with a repeat interval of 10 minutes;
  2. A way to fully disable the automation, would something goes out of control : That’s a “Traffic” node listening for allow/stop payloads with help from a “state” node to know if we want to allow or block commands;
  3. To prevent flooding the devices with messages, a rate limiting : That’s a “delay” node accepting one message every 0 seconds going through (here’s a reason to not trigger logic every minute or so);
  4. Some fairy dust to determine the target temperature based on current day, time and room being processed : That’s a not-so-easy “function” node;
  5. Again some optimization, a noise filter would a valve be already at the desired temperature : Again a “function” node to block message if valve is within desired settings;
  6. Finally, sending the temperature to the currently processed valve : That’s a “call service” node.

Let’s summarize : For each room, if in automated mode, get the desired target temperature and send command only if not already set.

Some deep dive now.

Triggers logic nodes

As I wanted to individually trigger rooms when debugging, I’ve added one “inject” node per room on top of the main one.

I’ve disabled “Inject once after …” to prevent issues would HA specific nodes not yet being available.

Tip : Note the “link out” node, this helps dramatically in improving readability of the flow.

Automation blocker

This part uses a boolean helper to fully turn off automation, regardless of the scenario (including automatic).

To keep messages nice and clean I convert the payload from “on/off” to “Allow/Stop” using a function node (I tend to prefer JS but a “change” node could as good). Well, it’s actually not even required to do so.

Here is “Radiators control” code.

if (msg.payload=="on") {
    return [ { payload: "Allow" } ];
}

if (msg.payload=="off") {
    return [ { payload: "Stop" } ];
}

Here is “Traffic Light” and “limit 1 msg/10s” JSON.

[{"id":"ee49a4f1.9cf8d8","type":"traffic","z":"8fcdcb3d.6fcf08","name":"","property_allow":"payload","filter_allow":"Allow","ignore_case_allow":true,"negate_allow":false,"send_allow":false,"property_stop":"payload","filter_stop":"Stop","ignore_case_stop":true,"negate_stop":false,"send_stop":false,"default_start":true,"differ":false,"x":190,"y":580,"wires":[["1b8a46d3.cf8059"]]},{"id":"1b8a46d3.cf8059","type":"delay","z":"8fcdcb3d.6fcf08","name":"","pauseType":"rate","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"10","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":380,"y":580,"wires":[["6345de41.6c01d"]]}]

Radiators temperature logic

This is where this gets dirty, be warned.

While looking simple, the complexity lies in the functions.

The first one, “Get room settings”, aims at looking up desired settings from the previously defined schedule. It relies on a couple of parameters : room, scenario (or strategy), day of week, time or AM/PM.

It’s main input is the “Stratégie” state :

Find below full code of “Get room settings”, it is made even more complex as I wanted to automate :

  • Wednesday PM : No school in the afternoon, boost living room;
  • Homeworking derived from main schedule : Boost home office room temperature during office hours;
  • Eco friendly limiter (again a Helper state) : Limit max temperature to 20°C regardless of scheduled temperature;
  • Fully automated : Switch between office days and week end schedules;
  • Special strategies for Guest / Full OFF / Full power;
  • Some leftovers from Z-Wave valves : Current Zigbee radiator valves from MOES are always in “heat” mode while my previous one had “heat” and “off” states, defining 8°C in scheduler turned them off.

That’s quite a list, I tried to comment the code, hope that’ll be good enough. I tend to use a lot of variables to improve reading of the code.

Note to myself : Move literals to constants.

Can you spot where to inject the generated JS schedules?

// Debug flags
var debugMode = false;
if (debugMode) node.warn("Get room settings - In");


// Consts
const cModeManuel      = "Manuel";
const cModeFullOff     = "Full OFF";
const cModeFullPower   = "Full power";
const cModeTeletravail = "Télétravail";
const cModeAutomatique = "Automatique";
const cModeGuests      = "Guests";
const cMaxTempEco      = 20;


// Setup temperature hashmap
var roomTemperaturesWeek = [{ "room": "Bureau", "entity_id": "climate.vanne_bureau_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 8, "h19": 8, "h20": 8, "h21": 8, "h22": 8, "h23": 8 }, { "room": "Chambre Kid", "entity_id": "climate.vanne_Kid_becathermostat", "h0": 16, "h1": 16, "h2": 16, "h3": 16, "h4": 16, "h5": 16, "h6": 16, "h7": 16, "h8": 16, "h9": 16, "h10": 16, "h11": 16, "h12": 16, "h13": 16, "h14": 16, "h15": 16, "h16": 16, "h17": 16, "h18": 16, "h19": 22, "h20": 18, "h21": 16, "h22": 16, "h23": 16 }, { "room": "Chambre Parents", "entity_id": "climate.vanne_parents_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 8, "h19": 18, "h20": 19, "h21": 19, "h22": 8, "h23": 8 }, { "room": "Cuisine", "entity_id": "climate.vanne_cuisine_becathermostat", "h0": 14, "h1": 14, "h2": 14, "h3": 14, "h4": 14, "h5": 14, "h6": 20, "h7": 22, "h8": 18, "h9": 18, "h10": 18, "h11": 20, "h12": 22, "h13": 18, "h14": 18, "h15": 18, "h16": 20, "h17": 20, "h18": 22, "h19": 20, "h20": 14, "h21": 14, "h22": 14, "h23": 14 }, { "room": "Hall", "entity_id": "climate.vanne_hall_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 8, "h19": 8, "h20": 8, "h21": 8, "h22": 8, "h23": 8 }, { "room": "Salon", "entity_id": "climate.vanne_salon_becathermostat", "h0": 18, "h1": 18, "h2": 18, "h3": 18, "h4": 18, "h5": 18, "h6": 20, "h7": 20, "h8": 17, "h9": 17, "h10": 17, "h11": 17, "h12": 17, "h13": 17, "h14": 20, "h15": 24, "h16": 22, "h17": 22, "h18": 21, "h19": 21, "h20": 21, "h21": 21, "h22": 21, "h23": 18 }, { "room": "Salle de bain", "entity_id": "climate.vanne_sdb_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 18, "h6": 26, "h7": 22, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 26, "h19": 24, "h20": 18, "h21": 8, "h22": 8, "h23": 8 }];
var roomTemperaturesWeekEnd = [{ "room": "Bureau", "entity_id": "climate.vanne_bureau_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 19, "h10": 19, "h11": 19, "h12": 19, "h13": 19, "h14": 19, "h15": 19, "h16": 19, "h17": 19, "h18": 19, "h19": 8, "h20": 8, "h21": 8, "h22": 8, "h23": 8 }, { "room": "Chambre Kid", "entity_id": "climate.vanne_Kid_becathermostat", "h0": 17, "h1": 17, "h2": 17, "h3": 17, "h4": 17, "h5": 17, "h6": 17, "h7": 17, "h8": 17, "h9": 20, "h10": 20, "h11": 21, "h12": 21, "h13": 21, "h14": 21, "h15": 21, "h16": 21, "h17": 21, "h18": 20, "h19": 20, "h20": 17, "h21": 17, "h22": 17, "h23": 17 }, { "room": "Chambre Parents", "entity_id": "climate.vanne_parents_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 8, "h19": 8, "h20": 8, "h21": 19, "h22": 19, "h23": 8 }, { "room": "Cuisine", "entity_id": "climate.vanne_cuisine_becathermostat", "h0": 18, "h1": 18, "h2": 18, "h3": 18, "h4": 18, "h5": 18, "h6": 18, "h7": 20, "h8": 20, "h9": 20, "h10": 20, "h11": 20, "h12": 20, "h13": 20, "h14": 20, "h15": 20, "h16": 20, "h17": 20, "h18": 20, "h19": 20, "h20": 18, "h21": 18, "h22": 18, "h23": 18 }, { "room": "Hall", "entity_id": "climate.vanne_hall_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 8, "h19": 8, "h20": 8, "h21": 8, "h22": 8, "h23": 8 }, { "room": "Salon", "entity_id": "climate.vanne_salon_becathermostat", "h0": 18, "h1": 18, "h2": 18, "h3": 18, "h4": 18, "h5": 18, "h6": 18, "h7": 21, "h8": 21, "h9": 21, "h10": 21, "h11": 21, "h12": 21, "h13": 21, "h14": 21, "h15": 21, "h16": 21, "h17": 21, "h18": 21, "h19": 21, "h20": 21, "h21": 21, "h22": 21, "h23": 18 }, { "room": "Salle de bain", "entity_id": "climate.vanne_sdb_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 26, "h9": 22, "h10": 18, "h11": 18, "h12": 18, "h13": 18, "h14": 18, "h15": 18, "h16": 18, "h17": 20, "h18": 26, "h19": 24, "h20": 20, "h21": 8, "h22": 8, "h23": 8 }];
var roomTemperaturesGuests = [{ "room": "Bureau", "entity_id": "climate.vanne_bureau_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 20, "h10": 20, "h11": 20, "h12": 20, "h13": 20, "h14": 20, "h15": 20, "h16": 20, "h17": 20, "h18": 20, "h19": 8, "h20": 8, "h21": 8, "h22": 8, "h23": 8 }, { "room": "Chambre Kid", "entity_id": "climate.vanne_Kid_becathermostat", "h0": 17, "h1": 17, "h2": 17, "h3": 17, "h4": 17, "h5": 17, "h6": 20, "h7": 20, "h8": 20, "h9": 21, "h10": 21, "h11": 21, "h12": 21, "h13": 21, "h14": 21, "h15": 21, "h16": 21, "h17": 21, "h18": 21, "h19": 21, "h20": 21, "h21": 20, "h22": 20, "h23": 20 }, { "room": "Chambre Parents", "entity_id": "climate.vanne_parents_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 8, "h8": 8, "h9": 8, "h10": 8, "h11": 8, "h12": 8, "h13": 8, "h14": 8, "h15": 8, "h16": 8, "h17": 8, "h18": 8, "h19": 8, "h20": 8, "h21": 19, "h22": 19, "h23": 8 }, { "room": "Cuisine", "entity_id": "climate.vanne_cuisine_becathermostat", "h0": 18, "h1": 18, "h2": 18, "h3": 18, "h4": 18, "h5": 18, "h6": 20, "h7": 20, "h8": 20, "h9": 21, "h10": 21, "h11": 21, "h12": 21, "h13": 21, "h14": 21, "h15": 21, "h16": 21, "h17": 21, "h18": 21, "h19": 21, "h20": 21, "h21": 21, "h22": 18, "h23": 18 }, { "room": "Hall", "entity_id": "climate.vanne_hall_becathermostat", "h0": 14, "h1": 14, "h2": 14, "h3": 14, "h4": 14, "h5": 14, "h6": 14, "h7": 14, "h8": 14, "h9": 14, "h10": 14, "h11": 14, "h12": 14, "h13": 14, "h14": 14, "h15": 14, "h16": 14, "h17": 14, "h18": 14, "h19": 14, "h20": 14, "h21": 14, "h22": 14, "h23": 14 }, { "room": "Salon", "entity_id": "climate.vanne_salon_becathermostat", "h0": 18, "h1": 18, "h2": 18, "h3": 18, "h4": 18, "h5": 18, "h6": 20, "h7": 22, "h8": 22, "h9": 22, "h10": 22, "h11": 22, "h12": 22, "h13": 22, "h14": 22, "h15": 22, "h16": 22, "h17": 22, "h18": 22, "h19": 22, "h20": 22, "h21": 22, "h22": 22, "h23": 15 }, { "room": "Salle de bain", "entity_id": "climate.vanne_sdb_becathermostat", "h0": 8, "h1": 8, "h2": 8, "h3": 8, "h4": 8, "h5": 8, "h6": 8, "h7": 20, "h8": 26, "h9": 26, "h10": 20, "h11": 20, "h12": 20, "h13": 20, "h14": 20, "h15": 20, "h16": 20, "h17": 20, "h18": 26, "h19": 26, "h20": 8, "h21": 8, "h22": 8, "h23": 8 }];


// Input cleanup
var lookupRoom = msg.payload.toLowerCase();
var roomTemps = null;


// Lookup inits
// -- Defaults
msg.tempStrategyMode = "Undefined";


// -- Week-end & special week days
var dt = new Date();
var isWeekEnd = false;
if (dt.getDay() == 6 || dt.getDay() == 0) {
    isWeekEnd = true;
}
msg.isWeekEnd = isWeekEnd;
var isWednesday = false;
if (dt.getDay() == 3) {
    isWednesday = true;
}
msg.isWednesday = isWednesday;


// -- Day part
var morningHours = dt.getHours() >= 8 && dt.getHours() <= 12; // 08:00 -> 12:59
var afternoonHours = dt.getHours() >= 12 && dt.getHours() <= 17; // 12:00 -> 17:59
var workingHours = dt.getHours() >= 7 && dt.getHours() <= 16; // 07:00 -> 16:59
msg.morningHours = morningHours;
msg.afternoonHours = afternoonHours;
msg.workingHours = workingHours;


// -- Strategy
const globalHomeAssistant = global.get('homeassistant').homeAssistant;
if (globalHomeAssistant.isConnected) {
    msg.tempStrategyMode = globalHomeAssistant.states["input_select.radiator_strategy_mode"].state;
} else {
    if (debugMode) node.warn("Get room settings - Not connected");
    return; // Drop message if not yet connected
}

switch (msg.tempStrategyMode) {
    case cModeManuel:
        // Do nothing, drop the message
        return;
    case cModeTeletravail:
    	if (isWeekEnd) {
        	roomTemps = roomTemperaturesWeekEnd;
    	} else {
        	roomTemps = roomTemperaturesWeek;
    	}
        break;
    case cModeAutomatique:
    	if (isWeekEnd) {
        	roomTemps = roomTemperaturesWeekEnd;
        	msg.tempStrategyMode = "Week-end";
    	} else {
        	roomTemps = roomTemperaturesWeek;
        	msg.tempStrategyMode = "Week time";
    	}
        break;
    case cModeGuests:
	    roomTemps = roomTemperaturesGuests;
        break;
    case cModeFullOff:
        roomTemps = roomTemperaturesWeek;
	    break;
    case cModeFullPower:
        roomTemps = roomTemperaturesWeek;
	    break;
    default:
        msg.status = "Unhandled strategy mode : " + msg.tempStrategyMode;
        if (debugMode) node.warn(msg.status);
}


// Lookup desired temperature based on room and time of the day
var foundIndex = -1;
var currentHour = "h"+(dt.getHours());
for (var i=0; i<roomTemps.length && foundIndex==-1; i++) {
  if (roomTemps[i].room.toLowerCase() == lookupRoom) {
    if (debugMode) node.warn("Loop: room setup for '"+lookupRoom+"'");
	foundIndex = i;
	
    // Overide rules : Manual specific cases
	if (msg.tempStrategyMode==cModeTeletravail
	    && workingHours
	    && lookupRoom == "bureau") {
	    if (debugMode) node.warn("For Télétravail, override Bureau temperature");
	    msg.temp = 20;
	} else if (isWednesday && afternoonHours && lookupRoom == "salon") {
	    if (debugMode) node.warn("Boost Salon for Wednesday");
	    msg.temp = 20;
	} else 	if (msg.tempStrategyMode == cModeFullOff) {
	    if (debugMode) node.warn("Full OFF : force to 8°");
	    msg.temp = 8;
	} else 	if (msg.tempStrategyMode == cModeFullPower) {
	    if (debugMode) node.warn("Full power : force to 28°");
	    msg.temp = 28;
	} else {
        msg.temp = roomTemps[foundIndex][currentHour];
	}

    msg.mode = msg.temp <= 8 ? "off" : "heat";
    msg.entity = roomTemps[foundIndex].entity_id;
  }
}

// Check if "Chauffage éco" is enabled, limiting max temp for all rooms
if ("on" == globalHomeAssistant.states["input_boolean.chauffage_eco"].state) {
	if (msg.temp > cMaxTempEco) {
		msg.temp = cMaxTempEco;
	}
}


// Update message body if we havet NOT found its settings
if (foundIndex == -1) {
    msg.status = "Unable to lookup heating settings for radiator of room" + lookupRoom;
    if (debugMode) node.warn(msg.status);
}


// All went fine, forward the message down the pipe
if (debugMode) node.warn("Get room settings - Out");
return msg;

Here comes the optimize “function” node.

var debugMode = false;
if (debugMode) node.warn("Optimize messages - In");


// Prevent sending a message if already at desired mode / temp
const globalHomeAssistant = global.get('homeassistant').homeAssistant;
var entityHA = globalHomeAssistant.states[msg.entity];
var currentEntityTemp = entityHA.attributes.temperature;
currentEntityTemp = currentEntityTemp ? currentEntityTemp : 0; // Prevent null

if (debugMode) node.warn(
    "entityHA.state : " + entityHA.state + "\n"
    + "msg.mode : " + msg.mode + "\n"
    + "entityHA.attributes.temperature : " + currentEntityTemp + "\n"
    + "msg.temp : " + msg.temp + "\n"
);
if (
        (entityHA.state == msg.mode)
        && (msg.mode=="off" || (Math.abs(currentEntityTemp - msg.temp) < 0.5))
    ) {
    msg.status = "At desired mode and within temp range";
} else {
    msg.status = "OK";
}


// All went fine, forward the message down the pipe
if (debugMode) node.warn("Optimize messages - Out");
return msg;

Sending the command to the valves

Finally, let’s control those valves!

Again there’s a leftover from the Z-wave valves, that’s why the “link in” node is skipping entirely the top part of the nodes.

For the curious, there’s again a 10 seconds delay when using the “Set mode” part to prevent a slow network from mishandling mode and temperature messages.

The “call service” node uses this JSON to fetch settings sent by the previous dark magic nodes.

{
    "entity_id": "{{entity}}",
    "temperature": "{{temp}}"
}

Conclusion

While this is in no way a starter project, it proved to be very interesting, mixing a lot of technologies : Home Assistant itself, Zigbee network, (very) basic plumbing to install valves, Node-RED for automation and some creative use of Google Sheets and Javascript.

Should I recommend it? Definitely.

Could it be improved? Always, my own TODO’s:

  • A “home / away” toggle;
  • Finer schedule (down to quarter or half hour?);
  • Link the scheduler to the main heater control (currently separated);
  • Use some presence detectors to better define occupied rooms.

Post Author: Shut

Leave a Reply

Your email address will not be published. Required fields are marked *