PID controller: Node red flow for indoor air quality control (CO2)

Our brand new apartment has a “modern” balanced air system, which is supposed to circulate the air, exchange heat conserving energy, and prevent build up of CO2 and condensation.

The issue I had with this is that we need to have the fans running at relatively high speeds 24x7 in order to keep the air fresh. The Netatmo weather station that I have also measures C02 levels and I see regular peaks in the main living area and the bedroom when they are in use. The recommendation is that the CO2 levels shouldn’t go much above 1000ppm, but in practice this meant that the system needs setting up with a permanently high fan speed for the 1 or 2 occasions per day when the CO2 peaks. This also introduces more acoustic noise which is just irritating!

This little project aimed to bring the indoor CO2 levels under better control by using a node-red flow in Home Assistant, keeping the fans at much lower values for most of the time and only turning them up when actually needed.

I have 2 external CO2 sensors, one in the living room and one in the bedroom (mine are from Netatmo). To keep it simple the node red flow just takes the highest value from the 2 sensors. These are fed into a PID controller (inspired by the power saver flows at Cascade temperature control). The PID controller determines the “load” (a value between 0 and 1) needed to ventilate well enough to bring the CO2 level down. If the CO2 level starts approaching, or going above the preset target level then the load increases and we can use this to set the fans.

My fan unit is the Flexit Nordic CL3 Nordic CL3 Flexit, and there is a new “flexit_bacnet” integration for controlling this. Flexit Nordic (BACnet) - Home Assistant

It is not possible to control the fans directly so this node red flow is set up to use the presets “away”, “home”, and “boost”, each with predefined fan speeds that I chose to be acceptable in terms of ventilation level and noise level. Internally these presets are numbered 0, 1, and 2 so that we can calculate which preset to use based on the load using a simple equation: load * 2.1. So a load of 0.4 would become preset 0.84, which equates to 0 (away) when rounded down. A load of 0.6 would give preset 1.26 which is 1 (home) when rounded down and the boost preset would be chosen for a high load approaching the maximum of 1.

The other thing that this flow does is to set the air temperature according to a schedule. This allows for a cooling effect during the night and warmer air during the day.

To use the flow you would need to set up some helpers in Home Assistant

input_number.co2_limit
input_number.fan_temperature

These are used by the flow to calculate the load and to (re)set the temperature when the fan setting changes. I have set the co2_limit to 900 which seems to work well.

input_number.co2_load
input_number.fan_mode

These are simply for display purposes but the flow will set these so that you can inspect them in the front-end to see what is going on.

If you want to you can set up a schedule and automation to change the fan_temperature helper as you like it.

Here is the flow that I am using, you will need to change any entity names to match your own:

[
    {
        "id": "79362a89844779c9",
        "type": "tab",
        "label": "CO2 controler",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "5f8f8732b7b85639",
        "type": "server-state-changed",
        "z": "79362a89844779c9",
        "name": "",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "outputs": 1,
        "exposeAsEntityConfig": "",
        "entityId": "sensor.fv65_indoor_co2",
        "entityIdType": "exact",
        "outputInitially": true,
        "stateType": "str",
        "ifState": "",
        "ifStateType": "str",
        "ifStateOperator": "is",
        "outputOnlyOnStateChange": true,
        "for": "0",
        "forType": "num",
        "forUnits": "minutes",
        "ignorePrevStateNull": false,
        "ignorePrevStateUnknown": false,
        "ignorePrevStateUnavailable": false,
        "ignoreCurrentStateUnknown": false,
        "ignoreCurrentStateUnavailable": false,
        "outputProperties": [
            {
                "property": "payload",
                "propertyType": "msg",
                "value": "",
                "valueType": "entityState"
            },
            {
                "property": "data",
                "propertyType": "msg",
                "value": "",
                "valueType": "eventData"
            },
            {
                "property": "topic",
                "propertyType": "msg",
                "value": "",
                "valueType": "triggerId"
            }
        ],
        "x": 190,
        "y": 260,
        "wires": [
            [
                "495c2c43e7592c1d"
            ]
        ]
    },
    {
        "id": "ac475faea010dac3",
        "type": "server-state-changed",
        "z": "79362a89844779c9",
        "name": "",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "outputs": 1,
        "exposeAsEntityConfig": "",
        "entityId": "sensor.fv65_bedroom_co2",
        "entityIdType": "exact",
        "outputInitially": true,
        "stateType": "str",
        "ifState": "",
        "ifStateType": "str",
        "ifStateOperator": "is",
        "outputOnlyOnStateChange": true,
        "for": "0",
        "forType": "num",
        "forUnits": "minutes",
        "ignorePrevStateNull": false,
        "ignorePrevStateUnknown": false,
        "ignorePrevStateUnavailable": false,
        "ignoreCurrentStateUnknown": false,
        "ignoreCurrentStateUnavailable": false,
        "outputProperties": [
            {
                "property": "payload",
                "propertyType": "msg",
                "value": "",
                "valueType": "entityState"
            },
            {
                "property": "data",
                "propertyType": "msg",
                "value": "",
                "valueType": "eventData"
            },
            {
                "property": "topic",
                "propertyType": "msg",
                "value": "",
                "valueType": "triggerId"
            }
        ],
        "x": 180,
        "y": 380,
        "wires": [
            [
                "2bca2771bf88883e"
            ]
        ]
    },
    {
        "id": "0d0712864857cb42",
        "type": "change",
        "z": "79362a89844779c9",
        "name": "choose higher value",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "$max([$number($flowContext(\"co2_1\")),$number($flowContext(\"co2_2\"))])\t",
                "tot": "jsonata"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 500,
        "y": 320,
        "wires": [
            [
                "8ceb930a94faeb2f"
            ]
        ]
    },
    {
        "id": "495c2c43e7592c1d",
        "type": "change",
        "z": "79362a89844779c9",
        "name": "store co2_1",
        "rules": [
            {
                "t": "set",
                "p": "co2_1",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 290,
        "y": 300,
        "wires": [
            [
                "0d0712864857cb42"
            ]
        ]
    },
    {
        "id": "2bca2771bf88883e",
        "type": "change",
        "z": "79362a89844779c9",
        "name": "store co2_2",
        "rules": [
            {
                "t": "set",
                "p": "co2_2",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 290,
        "y": 340,
        "wires": [
            [
                "0d0712864857cb42"
            ]
        ]
    },
    {
        "id": "c966af86802a2be8",
        "type": "PID",
        "z": "79362a89844779c9",
        "name": "",
        "setpoint": "",
        "pb": "600",
        "ti": "900",
        "td": "0",
        "integral_default": 0.5,
        "smooth_factor": "0",
        "max_interval": "600",
        "enable": 1,
        "disabled_op": 0,
        "x": 950,
        "y": 320,
        "wires": [
            [
                "21c6697ca1b4bcf0"
            ]
        ]
    },
    {
        "id": "8ceb930a94faeb2f",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "save current max value",
        "func": "flow.set(\"co2_max\", msg.payload);\nreturn msg;",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 730,
        "y": 320,
        "wires": [
            [
                "c966af86802a2be8"
            ]
        ]
    },
    {
        "id": "cdf2e2ac59d40e90",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "Required preset",
        "func": "msg.load = msg.payload\nmsg.topic = 'new_preset'\nmsg.fan_mode = 0\nconst modes = ['away', 'home', 'boost']\nvar m = msg.load * 2.1\n// 0 for away, 1 for home, 2 for Boost\nvar fan_mode = Math.round(m)\nif ((fan_mode >= 0) && (fan_mode <=2)) {\n  msg.fan_mode = Math.floor(m)\n}\nmsg.payload = modes[msg.fan_mode]\nreturn msg",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1300,
        "y": 320,
        "wires": [
            [
                "c7a26842db50a38f",
                "f2a2bb4de5bc59e9"
            ]
        ]
    },
    {
        "id": "ec3bf6916e88eaa2",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "save limit value",
        "func": "msg.topic = \"setpoint\"\nflow.set('setpoint',msg.payload)\nreturn msg",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 760,
        "y": 440,
        "wires": [
            [
                "c966af86802a2be8"
            ]
        ]
    },
    {
        "id": "21c6697ca1b4bcf0",
        "type": "range",
        "z": "79362a89844779c9",
        "minin": "0",
        "maxin": "1",
        "minout": "1",
        "maxout": "0",
        "action": "scale",
        "round": false,
        "property": "payload",
        "name": "Invert power",
        "x": 1110,
        "y": 320,
        "wires": [
            [
                "cdf2e2ac59d40e90"
            ]
        ]
    },
    {
        "id": "e332bd84d6174d7e",
        "type": "server-state-changed",
        "z": "79362a89844779c9",
        "name": "",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "outputs": 1,
        "exposeAsEntityConfig": "",
        "entityId": "input_number.co2_limit",
        "entityIdType": "exact",
        "outputInitially": true,
        "stateType": "str",
        "ifState": "",
        "ifStateType": "str",
        "ifStateOperator": "is",
        "outputOnlyOnStateChange": true,
        "for": "0",
        "forType": "num",
        "forUnits": "minutes",
        "ignorePrevStateNull": false,
        "ignorePrevStateUnknown": false,
        "ignorePrevStateUnavailable": false,
        "ignoreCurrentStateUnknown": false,
        "ignoreCurrentStateUnavailable": false,
        "outputProperties": [
            {
                "property": "payload",
                "propertyType": "msg",
                "value": "",
                "valueType": "entityState"
            },
            {
                "property": "data",
                "propertyType": "msg",
                "value": "",
                "valueType": "eventData"
            },
            {
                "property": "topic",
                "propertyType": "msg",
                "value": "",
                "valueType": "triggerId"
            }
        ],
        "x": 190,
        "y": 440,
        "wires": [
            [
                "ec3bf6916e88eaa2"
            ]
        ]
    },
    {
        "id": "1c1ce4c199595b08",
        "type": "api-call-service",
        "z": "79362a89844779c9",
        "name": "Set flexit fan mode",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "debugenabled": true,
        "domain": "climate",
        "service": "set_preset_mode",
        "areaId": [
            "technical"
        ],
        "deviceId": [
            "b422960859140c070456b8820c40efb5"
        ],
        "entityId": [
            "climate.flexit_nordic"
        ],
        "data": "{\"preset_mode\":\"{{payload}}\"}",
        "dataType": "json",
        "mergeContext": "",
        "mustacheAltTags": false,
        "outputProperties": [],
        "queue": "none",
        "x": 2230,
        "y": 380,
        "wires": [
            [
                "178511f927b014b3"
            ]
        ]
    },
    {
        "id": "8453bc1a3dd1a0de",
        "type": "server-state-changed",
        "z": "79362a89844779c9",
        "name": "Fan temperature",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "outputs": 1,
        "exposeAsEntityConfig": "",
        "entityId": "input_number.fan_temperature",
        "entityIdType": "exact",
        "outputInitially": true,
        "stateType": "str",
        "ifState": "",
        "ifStateType": "str",
        "ifStateOperator": "is",
        "outputOnlyOnStateChange": true,
        "for": "0",
        "forType": "num",
        "forUnits": "minutes",
        "ignorePrevStateNull": false,
        "ignorePrevStateUnknown": false,
        "ignorePrevStateUnavailable": false,
        "ignoreCurrentStateUnknown": false,
        "ignoreCurrentStateUnavailable": false,
        "outputProperties": [
            {
                "property": "payload",
                "propertyType": "msg",
                "value": "",
                "valueType": "entityState"
            },
            {
                "property": "data",
                "propertyType": "msg",
                "value": "",
                "valueType": "eventData"
            },
            {
                "property": "topic",
                "propertyType": "msg",
                "value": "",
                "valueType": "triggerId"
            }
        ],
        "x": 840,
        "y": 660,
        "wires": [
            [
                "59f4eeba314de1aa"
            ]
        ]
    },
    {
        "id": "140c45e9e4b6b993",
        "type": "api-call-service",
        "z": "79362a89844779c9",
        "name": "Set fan temperature",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "debugenabled": true,
        "domain": "climate",
        "service": "set_temperature",
        "areaId": [],
        "deviceId": [
            "b422960859140c070456b8820c40efb5"
        ],
        "entityId": [
            "climate.flexit_nordic"
        ],
        "data": "{\"entity_id\":\"climate.flexit_nordic\",\"temperature\":\"{{temperature}}\"}",
        "dataType": "json",
        "mergeContext": "",
        "mustacheAltTags": false,
        "outputProperties": [],
        "queue": "none",
        "x": 2240,
        "y": 600,
        "wires": [
            []
        ]
    },
    {
        "id": "cf109b2462832c6a",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "numeric fan temperature",
        "func": "//In case the climate entity can only handle integers\n//Calculate rounded setpoint for the climate entity and return the msg\nmsg.temperature=Math.round(msg.payload);\nreturn msg",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1290,
        "y": 660,
        "wires": [
            [
                "227b1cffb296f905"
            ]
        ]
    },
    {
        "id": "59f4eeba314de1aa",
        "type": "trigger",
        "z": "79362a89844779c9",
        "name": "Send when finished",
        "op1": "",
        "op2": "",
        "op1type": "nul",
        "op2type": "payl",
        "duration": "5",
        "extend": true,
        "overrideDelay": false,
        "units": "s",
        "reset": "",
        "bytopic": "all",
        "topic": "topic",
        "outputs": 1,
        "x": 1050,
        "y": 660,
        "wires": [
            [
                "cf109b2462832c6a"
            ]
        ]
    },
    {
        "id": "178511f927b014b3",
        "type": "delay",
        "z": "79362a89844779c9",
        "name": "",
        "pauseType": "delay",
        "timeout": "10",
        "timeoutUnits": "seconds",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "allowrate": false,
        "outputs": 1,
        "x": 1620,
        "y": 560,
        "wires": [
            [
                "c1d71329c9afd40c"
            ]
        ]
    },
    {
        "id": "227b1cffb296f905",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "save temperature value",
        "func": "msg.topic = \"temperature\"\nflow.set('temperature',msg.temperature)\nreturn msg",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1550,
        "y": 660,
        "wires": [
            [
                "ba328415503c6f5a"
            ]
        ]
    },
    {
        "id": "c1d71329c9afd40c",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "get current temperature setting",
        "func": "msg.topic = \"temperature\"\nmsg.temperature = flow.get('temperature')\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 1890,
        "y": 560,
        "wires": [
            [
                "140c45e9e4b6b993"
            ]
        ]
    },
    {
        "id": "c7a26842db50a38f",
        "type": "api-call-service",
        "z": "79362a89844779c9",
        "name": "",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "debugenabled": true,
        "domain": "input_number",
        "service": "set_value",
        "areaId": [],
        "deviceId": [],
        "entityId": [
            "input_number.co2_load"
        ],
        "data": "{\"value\" :$number(load) }",
        "dataType": "jsonata",
        "mergeContext": "",
        "mustacheAltTags": false,
        "outputProperties": [],
        "queue": "none",
        "x": 2250,
        "y": 320,
        "wires": [
            []
        ]
    },
    {
        "id": "ba328415503c6f5a",
        "type": "rbe",
        "z": "79362a89844779c9",
        "name": "Check for unchanged temperature setting",
        "func": "rbe",
        "gap": "10",
        "start": "",
        "inout": "out",
        "septopics": false,
        "property": "temperature",
        "topi": "topic",
        "x": 1860,
        "y": 660,
        "wires": [
            [
                "140c45e9e4b6b993"
            ]
        ]
    },
    {
        "id": "f2a2bb4de5bc59e9",
        "type": "join",
        "z": "79362a89844779c9",
        "name": "join presets",
        "mode": "custom",
        "build": "object",
        "property": "payload",
        "propertyType": "msg",
        "key": "topic",
        "joiner": "\\n",
        "joinerType": "str",
        "accumulate": true,
        "timeout": "",
        "count": "2",
        "reduceRight": false,
        "reduceExp": "",
        "reduceInit": "",
        "reduceInitType": "",
        "reduceFixup": "",
        "x": 1520,
        "y": 380,
        "wires": [
            [
                "42755f78f5a2581c"
            ]
        ]
    },
    {
        "id": "d942141d68c14ce8",
        "type": "server-state-changed",
        "z": "79362a89844779c9",
        "name": "Current preset",
        "server": "7c92bce9.8197c4",
        "version": 5,
        "outputs": 1,
        "exposeAsEntityConfig": "",
        "entityId": "sensor.flexit_preset_mode",
        "entityIdType": "exact",
        "outputInitially": true,
        "stateType": "str",
        "ifState": "",
        "ifStateType": "str",
        "ifStateOperator": "is",
        "outputOnlyOnStateChange": true,
        "for": "0",
        "forType": "num",
        "forUnits": "minutes",
        "ignorePrevStateNull": false,
        "ignorePrevStateUnknown": false,
        "ignorePrevStateUnavailable": false,
        "ignoreCurrentStateUnknown": false,
        "ignoreCurrentStateUnavailable": false,
        "outputProperties": [
            {
                "property": "payload",
                "propertyType": "msg",
                "value": "",
                "valueType": "entityState"
            },
            {
                "property": "topic",
                "propertyType": "msg",
                "value": "current_preset",
                "valueType": "str"
            }
        ],
        "x": 1070,
        "y": 440,
        "wires": [
            [
                "efb865f7121b4d29"
            ]
        ]
    },
    {
        "id": "42755f78f5a2581c",
        "type": "switch",
        "z": "79362a89844779c9",
        "name": "Compare with current preset",
        "property": "payload.current_preset",
        "propertyType": "msg",
        "rules": [
            {
                "t": "neq",
                "v": "payload.new_preset",
                "vt": "msg"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 1,
        "x": 1760,
        "y": 380,
        "wires": [
            [
                "4e80dde1e147a9a6"
            ]
        ],
        "info": "Compare the current and required presets. \r\nProceed to set the  new required preset if they are different."
    },
    {
        "id": "4e80dde1e147a9a6",
        "type": "function",
        "z": "79362a89844779c9",
        "name": "Force new preset",
        "func": "var new_payload = msg.payload.new_preset\nmsg.payload = new_payload\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 2010,
        "y": 380,
        "wires": [
            [
                "1c1ce4c199595b08"
            ]
        ]
    },
    {
        "id": "efb865f7121b4d29",
        "type": "trigger",
        "z": "79362a89844779c9",
        "name": "Send when finished",
        "op1": "",
        "op2": "",
        "op1type": "nul",
        "op2type": "payl",
        "duration": "12",
        "extend": false,
        "overrideDelay": false,
        "units": "min",
        "reset": "",
        "bytopic": "all",
        "topic": "topic",
        "outputs": 1,
        "x": 1290,
        "y": 440,
        "wires": [
            [
                "f2a2bb4de5bc59e9"
            ]
        ],
        "inputLabels": [
            "preset"
        ],
        "outputLabels": [
            "preset"
        ],
        "info": "Wait a suitable time for the current preset to be updated. It could have been changed externally and \r\nwe dont want to override it immediately."
    },
    {
        "id": "7c92bce9.8197c4",
        "type": "server",
        "name": "Home Assistant",
        "addon": true,
        "rejectUnauthorizedCerts": true,
        "ha_boolean": "",
        "connectionDelay": false,
        "cacheJson": false,
        "heartbeat": false,
        "heartbeatInterval": "",
        "statusSeparator": "",
        "enableGlobalContextStore": false
    }
]

There are several things that could be improved but this initial version has been working for the last 2-3 weeks during the Christmas period and performed well when needed.

The image below shows the 2 CO2 levels along the bottom, and the fan speeds changing as required along the top.

image

I hope you like this idea, please ask if you have any questions.

2 Likes