This will be a tutorial on how to set up heaters using Eurotronic Spirit actuators with PID regulator forum / documentation link.
For tldr; go to Current implementation section
Let me first share a bit of history why it ended like below. It was November 2018 when I first thought - “My wife is fiddling with regulators whole winter. It would be nice to have automation doing it.” (I’m not so sure I would start that jurney again )
So I read a bit and thought it’s doable (winter is comming). I decided to go with:
- RaspberryPi 3B+
- Aeotec z-stick gen5
- Eurotronic Spirit (I definitely don’t recommend those as “out the box solution” but I don’t have an alternative - suggestions are welcomed in comments)
So the first winter came. I spent hours configuring it and my wife told me to throw it out of the window and go back to manual ones (really).
Why?
- Constant Node disconnections
- Heaters going into installation mode
- Constant overheating the rooms
I was also really dissapointed but also stubborn and wanted to fix it.
It was easy to solve the first problem I thought one Z-wave stick is not handling my flat. Especially that the nodes are running on batteries so I bought two NEO Coolcam Zwave Outlets and set those up on 2 sides of my apartment. As those were constantly ON it worked and one problem was solved.
The second problem is still ongoing - one thing I learned is that Spirits need actual alkaline batteries. Those will not run properly on rechargeable ones (I tried Eneloop AA). So I limited the problem but 1 heater is still constantly ending up in installation mode. This is causing room overheating as it’s fully open when it’s happening (looking for any advice).
Solving the third one is a journey I think I didn’t finish yet. At first I thought well maybe the built-in thermometer is not doing its job. I bought another piece of equipment:
- Xiaomi Gateway
- Aquara temperature sensors
I set those up as external temp controls for Spirits. I had some more confidence in the measured temperature now. The heaters are next to windows so I figured out that the reported temperature by those was lover (even by 1.5 °C) compared to the actual temp in the room.
Setting up external sensors took time, money and didn’t really solve the problem.
The real problem was elsewhere. It was the built-in heating curve of the regulator. It was terribly slow. It was taking 2h-4h to shut down completely the actuator when the temperature was reached. Because of that issue room temperature was still growing as it was closing like 1% per 3 min (figured it out thanks to Influx + Grafana). In my case changing temperature at 20:00 to be like 19°C (while room was 22°C) - Spirit was fully closed ~1AM. Kind of insane.
I and many others using Spirits knew it would be best to control the valve open manually but at the beginning, it wasn’t implemented in Openhab. Second problem with controlling those was I didn’t have any way to predict what should be the valve state.
I started to play with the temperatures and set like 28°C in the morning to max out valve open and then 18°C to shut it down or even shutting those down (OFF command) to force close the valve.
The ultimate solution - PID controller.
I started to look at how to implement it myself and then all of a sudden @fwolter solution showed up.
I was able to change the Spirits mode to be manufacture specific and there would be the ability to set 0-100% of the valve instead providing desired temperature. This is what the below code will do.
Enough talking just tell me how to do it.
Current implementation
I used an abstraction layer to divide physical devices from the PID usage solution.
So in the room we physically have:
- 1 temp sensor in the heater
- 1 external temp sensor
- 1 valve that we need to control (dimmer)
- a possibility to set temperature manually on the heater
- a possibility to set temperature by schedule
Then I have a Virtual device:
- virtual room temperature calculated based on 2 sensors
- desired temperature from user or schedule (SetPoint)
- calculated dimmer state based on PID calculation
- PID controller feature
Let’s start with code calculating the Virtual Temp
In my solution will always pick Xiaomi over the heater one. It could be possible to sum and divide but that’s not my preference. If Xiaomi is unavailable I’ll take heater one just to get things running.
Problems: when Xiaomi goes offline it was setting up temp as 100 °C
If I have to pick heater one just add 1 °C to be more realistic.
Code:
/* Calculate virtual temperature based on external sensor and one built in heater */
/* Temp priority is set to external one as it has better readings - one could divide value to get avg */
/* Sensor in heater is close to window add + 1°C to read value because of it*/
var Room, Heater_Name,Temp_Sensor_Name
var logger = Java.type('org.slf4j.LoggerFactory').getLogger('org.openhab.rule.' + ctx.ruleUID);
Room = 'Gabinet';
Heater_Name = 'ZWaveNodeGabinet';
Temperature_Sensor_Name = 'XiaomiAqaraGabinet';
/* ------------ Common (copy/paste) ----------------- */
var Temp_Sensor_Property_Name, ThermostatTemp_Name, VirtualTemp_Name
var SensorTemp,ThermostatTemp, FinalTemp
Temp_Sensor_Property_Name = Temperature_Sensor_Name + '_Temperature';
ThermostatTemp_Name = Heater_Name + '_Currenttemperature';
VirtualTemp_Name = Room + '_Virtual_Temperature';
/* Get temperature */
SensorTemp = itemRegistry.getItem(Temp_Sensor_Property_Name).getState();
ThermostatTemp = itemRegistry.getItem(ThermostatTemp_Name).getState();
logger.info(([Room, ' | SensorTemp: ', SensorTemp, ' | ', 'Thermostat: ', ThermostatTemp].join('')));
/* If external sensor has problem pick heater temp */
/* Xiaomi has problem -> when battery will die it reports 100 °C */
if (SensorTemp != 'NULL') {
if (SensorTemp != 100){
FinalTemp = SensorTemp;
} else {
logger.warn('Xiaomi Sensor Reports wrong temp!');
FinalTemp = ThermostatTemp + 1;
}
} else {
FinalTemp = ThermostatTemp + 1;
}
logger.info((Room + ' | Final Calculated Temp: ' + String(FinalTemp)));
events.sendCommand(String(VirtualTemp_Name), FinalTemp);
The rule should run every minute to update Virtual temp:
Setting up a PID calculation is fairly straightforward. Figuring out the values so the solution would be fast and realiable is not (I simply reused some values others provided). PID can calculate values from plus infinite to minus infinite.
In my solution:
- value above 100 => actuator fully open
- value below 0 => fully closed
- rest goes to dimmer
triggers:
- id: "1"
configuration:
input: Gabinet_Virtual_Temperature
setpoint: Gabinet_Desired_SetPoint
kp: 65
kd: 0
kdTimeConstant: 0
commandItem: Gabinet_PID_Control
ki: 0.4
loopTime: 60000
type: pidcontroller.trigger
conditions: []
actions:
- inputs: {}
id: "3"
configuration:
itemName: Gabinet_Desired_Dimmer
type: core.ItemCommandAction
Now we need to have a piece of code to actually set up valve value.
/* Update Heater valve state based on value calculated by PID regulator */
var Room, Heater_Name;
Room = 'Gabinet';
Heater_Name = 'ZWaveNodeGabinet'
/* ------------ Common (copy/paste) ----------------- */
var DesiredDimmer_Name, ActualDimmer_Name;
var Desired_SetPoint_Name, Desired_SetPoint_Value;
var VirtualTemperature_Name, VirtualTemperature_Value;
var DesiredDimmerVal, DimmerNextVal, DimmerCurrentVal;
var logger = Java.type('org.slf4j.LoggerFactory').getLogger('org.openhab.rule.' + ctx.ruleUID);
DesiredDimmer_Name = Room + '_Desired_Dimmer';
ActualDimmer_Name = Heater_Name + '_Dimmer';
Desired_SetPoint_Name = Room + '_Desired_SetPoint';
VirtualTemperature_Name = Room + '_Virtual_Temperature';
/* For debugging purposes */
VirtualTemperature_Value = itemRegistry.getItem(VirtualTemperature_Name).getState();
Desired_SetPoint_Value = itemRegistry.getItem(Desired_SetPoint_Name).getState();
logger.info(([Room, 'PID_Dimmer_To_Command', 'Items:', DesiredDimmer_Name, ActualDimmer_Name, Desired_SetPoint_Name, VirtualTemperature_Name].join(' | ')));
logger.info(([Room, ' | PID_Dimmer_To_Command | ', 'Desired SetPoint : ', Desired_SetPoint_Value, ' | ', 'Current Temp : ', VirtualTemperature_Value].join('')));
DesiredDimmerVal = itemRegistry.getItem(DesiredDimmer_Name).getState();
DimmerCurrentVal = itemRegistry.getItem(ActualDimmer_Name).getState();
/* PID can calculate values from minus infite to plus infinite */
/* Acutator works in 0 - 100% of valve state that means */
/* anything below 0 like -256 means valve closed => 0% */
/* anything above 100 like +156 means full valve open => 100% */
if (DesiredDimmerVal != 'NULL') {
if (DesiredDimmerVal > 0) {
if (DesiredDimmerVal < 100) {
DimmerNextVal = Math.round(DesiredDimmerVal);
} else {
DimmerNextVal = 100;
}
} else {
DimmerNextVal = 0;
}
} else {
logger.Error('Desired Dimmer Value is not calculated properly');
DimmerNextVal = 0;
}
logger.info(([Room, ' | PID_Dimmer_To_Command | ', 'Desired Dimmer: ', DesiredDimmerVal, ' | ', 'Calculated Dimmer: ', DimmerNextVal, ' | ', 'Current Dimmer: ', DimmerCurrentVal].join('')));
/* Update valve state with primitive battery saving */
/* Update Heater only on change and force it every 5 minutes */
/* Force update is for situations when heater was offline like (battery died or instalation procedure showed up */
/* DelayItem_value is a simple item representing integer updated every single minute (+1) and reset to 0 past 6 (minutes) */
var DelayItem_value, DelayItem_Name
DelayItem = 'DelayItem';
DelayItem_value = itemRegistry.getItem(DelayItem).getState();
if (DimmerCurrentVal != DimmerNextVal) {
events.sendCommand(ActualDimmer_Name, DimmerNextVal);
} else if (DelayItem_value >= 5) {
logger.info(([Room, ' | PID_Dimmer_To_Command | Forcing Dimmer update | DelayItem_value: ', DelayItem_value].join('')));
events.sendCommand(ActualDimmer_Name, DimmerNextVal);
}
Run on every update of the Dimmer (PID execution)
Every time we need to change temperature we need to reset PID controller values.
triggers:
- id: "1"
configuration:
itemName: Gabinet_Desired_SetPoint
type: core.ItemStateUpdateTrigger
conditions: []
actions:
- inputs: {}
id: "2"
configuration:
itemName: Gabinet_PID_Control
command: RESET
type: core.ItemCommandAction
Now this all would work without issues but we need to think also about user input that would like to manually change temperature that was set up by schedule. We need to rewrite the desired temperature to the one that was set up on heater then reset PID and then start to controll valve.
When user enters value Spirit is changing mode to 1 (so crappy algorithm kicks in). I will take that value once the heater will settle.
- Update Virtual Item desired setpoint,
- Change heater back to manufacture mode
So next rule would be:
var Room, Heater_Name
Room = 'Gabinet';
Heater_Name = 'ZWaveNodeGabinet'
/* ------------ Common (copy/paste) ----------------- */
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min;
}
var Desired_SetPoint_Name, PID_Control_Name, ActualSetPoint_Name, Heater_BasicCommand,Heater_Thermostatmode
var CurrentVirtualSetPoint, PhysicalSetPoint;
var logger = Java.type('org.slf4j.LoggerFactory').getLogger('org.openhab.rule.' + ctx.ruleUID);
/* Sleep for a while so regulator could properly set up statuses */
java.lang.Thread.sleep(getRandomInt(3000, 5000));
/* Get the temperature that was set manually and rewrite it to be used by PID regulator */
Desired_SetPoint_Name = Room + '_Desired_SetPoint';
PID_Control_Name = Room + '_PID_Control'
ActualSetPoint_Name = Heater_Name + '_Setpointheat';
Heater_BasicCommand = Heater_Name + '_BasicDeprecated';
Heater_Thermostatmode = Heater_Name + '_Thermostatmode';
CurrentVirtualSetPoint = itemRegistry.getItem(Desired_SetPoint_Name).getState()
PhysicalSetPoint = itemRegistry.getItem(ActualSetPoint_Name).getState()
/* Don't allow to go below 18 °C */
if (PhysicalSetPoint < 18) {
PhysicalSetPoint = 18
}
logger.info(([Room, ' | Chainging Virtual Setpoint |', 'Previous value: ', CurrentVirtualSetPoint, ' | Recieved value: ', PhysicalSetPoint].join('')));
/* Set up Desired set point to user preffered*/
events.sendCommand(Desired_SetPoint_Name, PhysicalSetPoint);
/* Sleep for a while then switch to manual dimmer mode */
java.lang.Thread.sleep(getRandomInt(800, 1000));
events.sendCommand(Heater_BasicCommand, 0xFE);
events.sendCommand(Heater_Thermostatmode, 31);
/* Reset PID regulator values after temp change*/
events.sendCommand(PID_Control_Name, 'RESET');
All room rules:
Helper utilities:
I have a simple battery saver option by forcing update every 5 min - I’m using a simple item incrementing value every 1 min.
var logger = Java.type('org.slf4j.LoggerFactory').getLogger('org.openhab.rule.' + ctx.ruleUID);
var DelayItem_value
DelayItem_value = itemRegistry.getItem('DelayItem').getState();
logger.info(([' | DelayItem_value: ', DelayItem_value].join('')));
if (DelayItem_value <= 5) {
events.sendCommand(String('DelayItem'), DelayItem_value + 1);
} else {
events.sendCommand(String('DelayItem'), 1);
}
Synchronize heat values that was set is done every 20 min (in case some heaters would be down and missed the update timing) I’m processing all those at once in a loop (I left two things as a show up).
var ListOfThings, ThingsLen, SyncMode, i;
var logger = Java.type('org.slf4j.LoggerFactory').getLogger('org.openhab.rule.' + ctx.ruleUID);
logger.info((['| Validate_Heater_Mode', '-------------------------'].join(' | ')));
logger.info((['| Validate_Heater_Mode', 'START'].join(' | ')));
ListOfThings = [
{ z_wave: 'ZWaveNodeSypialnia', virtual_item: 'Sypialnia' },
{ z_wave: 'ZWaveNodeGabinet', virtual_item: 'Gabinet' },
];
var Z_waveThingName, VirtualThingName
SyncMode = itemRegistry.getItem('HeaterSyncMode').getState();
//SyncMode = 'VIRTUAL_HEATER';
//SyncMode = 'USER';
logger.info((['| Validate_Heater_Mode | SyncMode: ', SyncMode].join('')));
ThingsLen = ListOfThings.length;
for (i = 0; i < ThingsLen; i++) {
Z_waveThingName = ListOfThings[i].z_wave;
VirtualThingName = ListOfThings[i].virtual_item;
logger.info((['| Validate_Heater_Mode | Items: ', Z_waveThingName, ' : ', VirtualThingName].join('')));
SynchroniseSetPointValues(SyncMode, Z_waveThingName, VirtualThingName);
SetManufacturerMode(Z_waveThingName);
SetHeaterMode(Z_waveThingName);
}
logger.info((['| Validate_Heater_Mode |', '-----------STOP--------------'].join(' | ')));
function SynchroniseSetPointValues(SyncMode, ZwaveName, DesiredName) {
SynchroniseItems(SyncMode, ZwaveName, DesiredName);
function SynchroniseItems(SyncMode, ZwaveName, DesiredName) {
var Z_waveThing_SetPoint_Value;
var Desired_SetPoint_Value;
Z_waveThing_SetPoint_Value = GetSetPointValue(GetZwaveSetpointName(ZwaveName));
Desired_SetPoint_Value = GetSetPointValue(GetDesiredSetPointName(DesiredName));
logger.info((['| Validate_Heater_Mode | SetPoint: z_wave : ', Z_waveThing_SetPoint_Value, ' Desired: ', Desired_SetPoint_Value, ' |'].join('')));
if (Z_waveThing_SetPoint_Value != Desired_SetPoint_Value) {
if (SyncMode == 'VIRTUAL_HEATER') {
SetItemSetPoint(GetZwaveSetpointName(ZwaveName), Desired_SetPoint_Value);
} else if (SyncMode == 'USER') {
SetItemSetPoint(GetDesiredSetPointName(DesiredName), Z_waveThing_SetPoint_Value);
}
}
else
logger.info((['| Validate_Heater_Mode | Nothing to do for SetPoint | '].join('')));
}
function GetSetPointValue(SetPointName) {
var _SetPoint_Value
_SetPoint_Value = itemRegistry.getItem(SetPointName).getStateAs(DecimalType.class).doubleValue();
if (_SetPoint_Value == null) {
_SetPoint_Value = DecimalType.ZERO;
}
return _SetPoint_Value
}
function GetDesiredSetPointName(DesiredName) {
return DesiredName + '_Desired_SetPoint';
}
function GetZwaveSetpointName(ZwaveName) {
return ZwaveName + '_Setpointheat';
}
function SetItemSetPoint(SetPointheatItemName, SetPoint_Value) {
if ((SetPoint_Value != NULL) && (SetPoint_Value != 0)) {
logger.info((['| Validate_Heater_Mode | Setting up SetPoint - z_wave = ', SetPoint_Value, ' |'].join('')));
events.sendCommand(SetPointheatItemName, SetPoint_Value);
} else
logger.error((['| Validate_Heater_Mode | ', Desired_SetPoint_Name, ' | SetPoint is NULL |'].join('')));
}
}
function SetManufacturerMode(Z_waveThingName) {
Thermostatmode_BasicDeprecated = itemRegistry.getItem(Z_waveThingName + '_BasicDeprecated').getState();
logger.info((['| Validate_Heater_Mode | ', Z_waveThingName, ' | BasicDeprecated: ', Thermostatmode_BasicDeprecated].join('')));
if (Thermostatmode_BasicDeprecated != '254') {
logger.info((['| Validate_Heater_Mode | ', Z_waveThingName, ' | Swtich to Manufactore Mode', ' | Basic', Thermostatmode_BasicDeprecated].join('')));
events.sendCommand(Z_waveThingName + '_BasicDeprecated', 0xFE);
} else
logger.info((['| Validate_Heater_Mode | ', Z_waveThingName, ' | Nothing to do for BasicDeprecated | '].join('')));
}
function SetHeaterMode(Z_waveThingName) {
Thermostatmode = itemRegistry.getItem(Z_waveThingName + '_Thermostatmode').getState();
logger.info((['| Validate_Heater_Mode | ', Z_waveThingName, ' | Thermostatmode: ', Thermostatmode].join('')));
if (Thermostatmode == '1') {
logger.info((['| Validate_Heater_Mode | ', Z_waveThingName, ' | Swtich to Manual Mode'].join('')));
events.sendCommand(Z_waveThingName + '_Thermostatmode', 31);
} else
logger.info((['| Validate_Heater_Mode | ', Z_waveThingName, ' | Nothing to do for Thermostatmode | '].join('')));
}
I have a “feature” to override the heater input. Anything past 7:30 AM is controlled by user, anything past 19 is a VirtualHeater (cron)
triggers:
- id: "1"
configuration:
time: 07:30
type: timer.TimeOfDayTrigger
conditions: []
actions:
- inputs: {}
id: "2"
configuration:
itemName: HeaterSyncMode
command: USER
type: core.ItemCommandAction
triggers:
- id: "1"
configuration:
time: 19:05
type: timer.TimeOfDayTrigger
conditions: []
actions:
- inputs: {}
id: "2"
configuration:
itemName: HeaterSyncMode
command: VIRTUAL_HEATER
type: core.ItemCommandAction
Xiaomi was going offline quite frequently so I had to restart it
triggers:
- id: "1"
configuration:
thingUID: mihome:gateway:d18d317234:7c49ebb19279
status: OFFLINE
type: core.ThingStatusChangeTrigger
conditions: []
actions:
- inputs: {}
id: "2"
configuration:
type: application/vnd.openhab.dsl.rule
script: >
logInfo("XiaomiBridgeOffline", "XiaomiBridge is offline!")
var String result = executeCommandLine(Duration.ofSeconds(60),"/bin/sh", "-c", "$OPENHAB_RUNTIME/bin/client -p password bundle:restart org.openhab.binding.mihome")
logInfo("result", result)
logInfo("mihome:binding", "mihome:binding restarted")
type: script.ScriptAction
Now finally.
It seems that Spirits are having sometimes problems to fully open and fully close. I created a simple rule to set up BOOST first to cause full open. Then I’m shutting those down (OFF) and finally I’m switching those ON.
BOOST
triggers:
- id: "1"
configuration:
time: 20:15
type: timer.TimeOfDayTrigger
conditions: []
actions:
- inputs: {}
id: "9"
configuration:
itemName: ZWaveNodeMalaLazienka_Thermostatmode
command: "15"
type: core.ItemCommandAction
OFF
triggers:
- id: "1"
configuration:
time: 20:17
type: timer.TimeOfDayTrigger
conditions: []
actions:
- inputs: {}
id: "9"
configuration:
itemName: ZWaveNodeMalaLazienka_Thermostatmode
command: "0"
type: core.ItemCommandAction
ON
triggers:
- id: "1"
configuration:
time: 20:30
type: timer.TimeOfDayTrigger
conditions: []
actions:
- inputs: {}
id: "9"
configuration:
itemName: ZWaveNodeMalaLazienka_Thermostatmode
command: "1"
type: core.ItemCommandAction
If you think about having such setup think twice as it was a constant battle. At the same time, I hope that this is a solid starting point so time is “wasted” on a much better solution at the end that I also could benefit
Setup summary:
- 1x RaspberryPi 3B+ and OpenHabian 3.1
- 1x Aeotec z-stick gen5
- 2x NEO Coolcam Zwave Outlets
- 8x Eurotronic Spirit
- 1x Xiaomi Gateway
- 9x Aquara temperature sensors
Possible upgrades:
I have a set of rules for every room - I don’t have time now but it would be best to rewrite it to process a list in a loop (like the last script for synchronization) and have 1 rule instead of set per room. This would significantly reduce complexity.
Disable heating control if avg temp at home is above X - like automatic summer break instead of rules by dates etc. So take all the sensors and calculate a value.
I still don’t have any proper UI for this solution so some templated would be helpfull.
PI4 was sometimes to slow to show Influx query on Grafana.
PS. Remember that if you will pick RaspberryPi 4 you have to pick Aeotec z-stick gen5 + (plus)