├── .gitignore ├── LICENSE ├── README.md ├── components └── comfoair │ ├── CanAddress.cpp │ ├── CanAddress.h │ ├── __init__.py │ ├── comfoair.h │ └── commands.h └── docs ├── climate.png ├── esphome_sample.yaml ├── homeassistant.png ├── pcb ├── BOM_PCB_comfoair_2_2023-04-28.csv └── Gerber_PCB_comfoair_2_2023-04-28.zip └── pic.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | .pio 2 | .vscode/.browse.c_cpp.db* 3 | .vscode/c_cpp_properties.json 4 | .vscode/launch.json 5 | .vscode/ipch 6 | .vscode/ 7 | src/secrets.h 8 | .idea/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2012 Andrea Baccega 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | {"mode":"full","isActive":false} 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Need help? Join the [Discord](https://discord.gg/dUSSRezJXC) or open an issue. 2 | 3 | # Comfoair Q 350 ESPHome bridge 4 | 5 | This software let you use a ESP32 + CAN Transceiver to interact with the Comfoair Q 350 unit. 6 | 7 | It does expose all known informations of your Comfoair unit via ESPHOME and let you control the air flow via Home Assistant as well. 8 | 9 | It does allow you to integrate the unit on Home Assistant as depicted below: 10 | ![Comfoair Q 350 Home Assistant](docs/homeassistant.png?raw=true "Comfoair Q 350 Home Assistant") 11 | 12 | ## Custom PCB 13 | 14 | Inside the [docs/pcb folder](docs/pcb) you can find both gerber file and BOM. Files are provided as is. It's up to you to check data and schematic correctness. 15 | 16 | This repository is meant to be working with the custom pcb above. But you can always buy components separately such as [Waveshare SN65HVD230](https://www.banggood.com/Waveshare-SN65HVD230-CAN-Bus-Module-Communication-CAN-Bus-Transceiver-Development-Board-p-1693712.html?rmmds=myorder&cur_warehouse=CN) and a voltage regulator as well as an ESP32 of your choice and it will work. 17 | 18 | The pcb will look like this once soldered and mounted. 19 | 20 | ![PCB](docs/pic.jpg?raw=true "Comfoair Q 350 3D Print") 21 | 22 | ## DIY 23 | 24 | Prerequisites for custom build: 25 | 26 | * `ESP32` -> [link](https://amzn.to/3pe0XVP) 27 | * `DC-DC converter` -> [link](https://amzn.to/39ar22v) 28 | * `RJ45 Female` -> [link](https://amzn.to/3sNx3tH) 29 | * `Can Transceiver` -> [Waveshare SN65HVD230](https://www.banggood.com/Waveshare-SN65HVD230-CAN-Bus-Module-Communication-CAN-Bus-Transceiver-Development-Board-p-1693712.html?rmmds=myorder&cur_warehouse=CN) 30 | + Some ethernet cable 31 | 32 | 33 | Here a simple schematic. made by @mat3u 34 | 35 | ``` 36 | ---------------+ +---------------+ +-------------+ 37 | (oran/red)12V o--------o IN+ OUT+ o-----------------o VIN | 38 | | | [LM2596S] | | | 39 | (brown) GND o--------o IN- OUT- o-----------------o GND | 40 | | +---------------+ | | 41 | [RJ45] | | [ESP32] | 42 | [ComfoAir] | | | 43 | | +-------------------------+ | | 44 | (w/blue) CAN_L o--------o CAN_L 3v3 o-------o 3v3 | 45 | (blue) CAN_H o--------o CAN_H GND o-------o GND | 46 | ---------------+ | | | | 47 | | [SN65HVD230] CAN TX o-------o 25 | 48 | | CAN RX o-------o 21 | 49 | +-------------------------+ +-------------+ 50 | `````` 51 | 52 | ## esphome conf 53 | 54 | To include this repo use the external_components configuration like so: 55 | 56 | ```yaml 57 | # ... 58 | external_components: 59 | - source: github://vekexasia/comfoair-esp32 60 | components: [ comfoair ] 61 | api: 62 | custom_services: true # <--- This is needed 63 | 64 | comfoair: 65 | 66 | # ... 67 | ``` 68 | 69 | 70 | # Home Assistant Services 71 | 72 | The component exposes 4 services. 73 | 74 | - comfoair_send_command 75 | - comfoair_send_hex 76 | - comfoair_update_all 77 | - comfoair_req_update_service 78 | 79 | **NOTE**: the prefix could differ depending on your esphome device name. 80 | 81 | **comfoair_send_command**: allows one "command" parameter. The value of the param can be one of the following pre-bundled commands: 82 | 83 | - ventilation_level_0 84 | - ventilation_level_1 85 | - ventilation_level_2 86 | - ventilation_level_3 87 | - boost_10_min 88 | - boost_20_min 89 | - boost_30_min 90 | - boost_60_min 91 | - boost_end 92 | - auto 93 | - manual 94 | - bypass_activate_1h 95 | - bypass_deactivate_1h 96 | - bypass_auto 97 | - ventilation_supply_only 98 | - ventilation_supply_only_reset 99 | - ventilation_extract_only 100 | - ventilation_extract_only_reset 101 | - temp_profile_normal 102 | - temp_profile_cool 103 | - temp_profile_warm 104 | 105 | In case you need more flexibility you can use the `comfoair_send_hex` service like follows: 106 | ``` 107 | service: esphome.comfoair_send_hex 108 | data: 109 | hexSequence: "8415070100000000100e000000" 110 | ``` 111 | ^^ the above is equivalent to ventilation_extract_only (which has an inner timer of 1h) 112 | 113 | **comfoair_update_all**: this service will request an update of all the values from the Comfoair unit. This is useful in case you want to force an update of all the values. 114 | 115 | **comfoair_req_update_service**: this service will request an update of a specific value from the Comfoair unit. This is useful in case you want to force an update of a specific value. You're required to pass the `PDOID` of the service to update 116 | 117 | ## Climate Entity 118 | 119 | The component will expose a climate entity with heat,cool,auto modes and all the fan speeds. 120 | 121 | Furthermore current indoor humidity and temperature as well as current target temperature will be shon as in the picture below. 122 | 123 | ![climate.png](docs/climate.png) 124 | 125 | 126 | The climate entity can also be used to set the fan speed, and the mode (heat, cool, auto). 127 | 128 | ## Global filters 129 | 130 | Since this component posts data as soon as it receives it, sometimes it might be useful to avoid spamming home assistant with updates. 131 | 132 | To avoid this, you can set the `global_filters`. This accepts the same filters as the `filters` parameter in the `sensor` component. 133 | 134 | For example, let's say you want to throttle the sensors for 10s. 135 | 136 | ```yaml 137 | 138 | comfoair: 139 | global_filters: 140 | - throttle: 10s 141 | ``` 142 | 143 | Obviously, you can always set the filter on a per-sensor basis. 144 | 145 | ```yaml 146 | comfoair: 147 | supply_fan_speed: 148 | name: supply_fan_speed 149 | disabled_by_default: false 150 | force_update: false 151 | unit_of_measurement: rpm 152 | accuracy_decimals: 0 153 | filters: 154 | - or: 155 | - throttle: 300s 156 | - delta: 100.0 157 | ``` 158 | 159 | **NOTE**: when setting `global_filters` that **does** takes precedence over the `filters` set on a per-sensor basis. 160 | ## Credits 161 | 162 | A lot of this repo was inspired by the reverse engineering [here](https://github.com/marco-hoyer/zcan/issues/1). 163 | If you'd like to know more how the unit communicates, head over 164 | 165 | * [here](https://github.com/michaelarnauts/aiocomfoconnect/blob/master/docs/PROTOCOL-RMI.md) 166 | * [and here](https://github.com/michaelarnauts/aiocomfoconnect/blob/master/docs/PROTOCOL-PDO.md) 167 | 168 | -------------------------------------------------------------------------------- /components/comfoair/CanAddress.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include "CanAddress.h" 3 | namespace esphome { 4 | namespace comfoair { 5 | 6 | CanAddress::CanAddress(uint8_t srcAddr, uint8_t dstAddr, uint8_t unknownCounter, bool multimsg, bool errorOccurred, bool isRequest, uint8_t seqNr) { 7 | this->srcAddr = srcAddr; 8 | this->dstAddr = dstAddr; 9 | this->unknownCounter = unknownCounter; 10 | this->multimsg = multimsg; 11 | this->errorOccurred = errorOccurred; 12 | this->isRequest = isRequest; 13 | this->seqNr = seqNr; 14 | } 15 | 16 | uint32_t CanAddress::canID() { 17 | uint32_t addr = 0; 18 | addr |= this->srcAddr << 0; 19 | addr |= this->dstAddr << 6; 20 | addr |= this->unknownCounter << 12; 21 | addr |= (this->multimsg ? 1: 0) << 14; 22 | addr |= (this->errorOccurred ? 1: 0) << 15; 23 | addr |= (this->isRequest ? 1 : 0) << 16; 24 | addr |= this->seqNr << 17; 25 | addr |= 0x1f << 24; 26 | return addr; 27 | } 28 | 29 | void CanAddress::canIDBuf(char *buf) { 30 | uint32_t canID = this->canID(); 31 | buf[0] = (canID >> 24) & 0xFF; 32 | buf[1] = (canID >> 16) & 0xFF; 33 | buf[2] = (canID >> 8) & 0xFF; 34 | buf[3] = canID & 0xFF; 35 | } 36 | 37 | } 38 | } -------------------------------------------------------------------------------- /components/comfoair/CanAddress.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | 4 | #include 5 | #include 6 | namespace esphome { 7 | namespace comfoair { 8 | class CanAddress { 9 | public: 10 | CanAddress(uint8_t srcAddr, uint8_t dstAddr, uint8_t unknownCounter, bool multimsg, bool errorOccurred, bool isRequest, uint8_t seqNr); 11 | uint32_t canID(); 12 | void canIDBuf(char *buf); 13 | private: 14 | uint8_t srcAddr, dstAddr, unknownCounter, seqNr; 15 | bool multimsg, errorOccurred, isRequest; 16 | }; 17 | } 18 | } -------------------------------------------------------------------------------- /components/comfoair/__init__.py: -------------------------------------------------------------------------------- 1 | import math 2 | import esphome.codegen as cg 3 | import esphome.cpp_generator as cppg 4 | import esphome.config_validation as cv 5 | from esphome import pins 6 | from esphome.core import ID 7 | from esphome.util import Registry 8 | from esphome.const import CONF_ID, CONF_VERSION, CONF_NAME, UNIT_PERCENT, CONF_RX_PIN, CONF_TX_PIN 9 | from esphome.components import text_sensor, binary_sensor, sensor 10 | from enum import Enum 11 | 12 | DEPENDENCIES = ['climate'] 13 | CONF_GLOBAL_FILTERS = "global_filters" 14 | 15 | AUTO_LOAD = ["text_sensor", "binary_sensor", "sensor", "climate"] 16 | MULTI_CONF = True 17 | 18 | CONF_HUB_ID = 'comfoair' 19 | 20 | empty_sensor_hub_ns = cg.esphome_ns.namespace('comfoair') 21 | 22 | Comfoair = empty_sensor_hub_ns.class_('Comfoair', cg.Component) 23 | ComfoairClimate = empty_sensor_hub_ns.class_('ComfoairClimate', cg.Component) 24 | class ComfoNumConvs(Enum): 25 | UINT8 = 0, 26 | UINT16 = 1, 27 | UINT32 = 2, 28 | INT16 = 3 29 | 30 | FILTER_REGISTRY = Registry() 31 | validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) 32 | sensors = { 33 | "fan_speed": {"unit": "", "PDO": 65, "CONV": ComfoNumConvs.UINT8}, 34 | "next_fan_change": {"unit": "s", "PDO": 81, "CONV": ComfoNumConvs.UINT32}, 35 | "next_bypass_change": {"unit": "s", "PDO": 82, "CONV": ComfoNumConvs.UINT32}, 36 | "next_supply_change": {"unit": "s", "PDO": 86, "CONV": ComfoNumConvs.UINT32}, 37 | "next_exhaust_change": {"unit": "s", "PDO": 87, "CONV": ComfoNumConvs.UINT32}, 38 | 39 | "exhaust_fan_duty": {"unit":"%", "PDO": 117, "CONV": ComfoNumConvs.UINT8 }, 40 | "supply_fan_duty": {"unit":"%", "PDO": 118, "CONV": ComfoNumConvs.UINT8 }, 41 | "exhaust_fan_flow": {"unit":"m³/h", "PDO": 119, "CONV": ComfoNumConvs.UINT16}, 42 | "supply_fan_flow": {"unit":"m³/h", "PDO": 120, "CONV": ComfoNumConvs.UINT16}, 43 | "exhaust_fan_speed": {"unit":"rpm", "PDO": 121, "CONV": ComfoNumConvs.UINT16}, 44 | "supply_fan_speed": {"unit":"rpm", "PDO": 122, "CONV": ComfoNumConvs.UINT16}, 45 | 46 | "power_consumption_current": {"unit": "W", "PDO": 128, "CONV": ComfoNumConvs.UINT16}, 47 | "energy_consumption_ytd": {"unit": "kWh", "PDO": 129, "CONV": ComfoNumConvs.UINT16}, 48 | "energy_consumption_since_start": {"unit": "kWh", "PDO": 130, "CONV": ComfoNumConvs.UINT16}, 49 | 50 | "days_remaining_filter": {"unit": "days", "PDO": 192, "CONV": ComfoNumConvs.UINT16}, 51 | 52 | "avoided_heating_actual": {"unit": "W", "PDO": 213, "CONV": ComfoNumConvs.UINT16}, 53 | "avoided_heating_ytd": {"unit": "kWh", "PDO": 214, "CONV": ComfoNumConvs.UINT16}, 54 | "avoided_heating_total": {"unit": "kWh", "PDO": 215, "CONV": ComfoNumConvs.UINT16}, 55 | 56 | "avoided_cooling_actual": {"unit": "W", "PDO": 216, "CONV": ComfoNumConvs.UINT16}, 57 | "avoided_cooling_ytd": {"unit": "kWh", "PDO": 217, "CONV": ComfoNumConvs.UINT16}, 58 | "avoided_cooling_total": {"unit": "kWh", "PDO": 218, "CONV": ComfoNumConvs.UINT16}, 59 | 60 | "bypass_state": {"unit": "%", "PDO": 227, "CONV": ComfoNumConvs.UINT8}, 61 | 62 | # temps 63 | "rmot": {"unit": "°C", "PDO": 209, "CONV": ComfoNumConvs.INT16, "div": 10}, 64 | "target_temp": {"unit": "°C", "PDO": 212, "CONV": ComfoNumConvs.UINT16, "div": 10}, 65 | "pre_heater_temp_before": {"unit": "°C", "PDO": 220, "CONV": ComfoNumConvs.INT16, "div": 10}, 66 | "post_heater_temp_before": {"unit": "°C", "PDO": 221, "CONV": ComfoNumConvs.INT16, "div": 10}, 67 | "extract_air_temp": {"unit": "°C", "PDO": 274, "CONV": ComfoNumConvs.INT16, "div": 10}, 68 | "exhaust_air_temp": {"unit": "°C", "PDO": 275, "CONV": ComfoNumConvs.INT16, "div": 10}, 69 | "outdoor_air_temp": {"unit": "°C", "PDO": 276, "CONV": ComfoNumConvs.INT16, "div": 10}, 70 | 71 | "pre_heater_temp_after": {"unit": "°C", "PDO": 277, "CONV": ComfoNumConvs.INT16, "div": 10}, 72 | "post_heater_temp_after": {"unit": "°C", "PDO": 278, "CONV": ComfoNumConvs.INT16, "div": 10}, 73 | 74 | # humidity 75 | "extract_air_humidity": {"unit": "%", "PDO": 290, "CONV": ComfoNumConvs.UINT8}, 76 | "exhaust_air_humidity": {"unit": "%", "PDO": 291, "CONV": ComfoNumConvs.UINT8}, 77 | "outdoor_air_humidity": {"unit": "%", "PDO": 292, "CONV": ComfoNumConvs.UINT8}, 78 | "pre_heater_hum_after": {"unit": "%", "PDO": 293, "CONV": ComfoNumConvs.UINT8}, 79 | "supply_air_humidity": {"unit": "%", "PDO": 294, "CONV": ComfoNumConvs.UINT8}, 80 | 81 | 82 | # analog ports 83 | "analog_input_0_10v_1": {"unit": "V", "PDO": 369, "CONV": ComfoNumConvs.UINT8, "div": 10}, 84 | "analog_input_0_10v_2": {"unit": "V", "PDO": 370, "CONV": ComfoNumConvs.UINT8, "div": 10}, 85 | "analog_input_0_10v_3": {"unit": "V", "PDO": 371, "CONV": ComfoNumConvs.UINT8, "div": 10}, 86 | "analog_input_0_10v_4": {"unit": "V", "PDO": 372, "CONV": ComfoNumConvs.UINT8, "div": 10}, 87 | 88 | } 89 | 90 | textSensors = { 91 | "operating_mode": { 92 | "PDO": 49, 93 | "code": ''' 94 | return vals[0] == 1 ? "limited_manual": (vals[0] == 0xff ? "auto": "unlimited_manual"); 95 | ''' 96 | }, 97 | "bypass_activation_mode": { 98 | "PDO": 66, 99 | "code": ''' 100 | return vals[0] == 0 ? "auto": (vals[0] == 1 ? "activated": "deactivated"); 101 | ''' 102 | }, 103 | "temp_profile": { 104 | "PDO": 67, 105 | "code": ''' 106 | return vals[0] == 0 ? "auto": (vals[0] == 1 ? "cold": "warm"); 107 | ''' 108 | }, 109 | 110 | } 111 | 112 | binarySensors = { 113 | # summer/winter mode 114 | "heating_season": { 115 | "unit": "", 116 | "PDO": 210, 117 | "code": ''' 118 | return vals[0] == 1; 119 | ''' 120 | }, 121 | "cooling_season": { 122 | "unit": "", 123 | "PDO": 211, 124 | "code": ''' 125 | return vals[0] == 1; 126 | ''' 127 | }, 128 | "manual_mode": { 129 | "PDO": 56, 130 | "code": ''' 131 | return vals[0] == 1; 132 | ''' 133 | }, 134 | "away": { 135 | "PDO": 16, 136 | "code": ''' 137 | return vals[0] == 0x08; 138 | ''' 139 | }, 140 | 141 | "fan_supply_only": { 142 | "PDO": 70, 143 | "code": ''' 144 | return vals[0] != 0; 145 | ''' 146 | }, 147 | "fan_exhaust_only": { 148 | "PDO": 71, 149 | "code": ''' 150 | return vals[0] != 0; 151 | ''' 152 | }, 153 | } 154 | GEN_SENSORS_SCHEMA = { 155 | cv.Optional(key, default=key): cv.maybe_simple_value( 156 | sensor.sensor_schema(unit_of_measurement=value['unit'], accuracy_decimals=math.trunc(math.log10(value['div'])) if 'div' in value else 0), key=CONF_NAME, ) 157 | for key, value in sensors.items() 158 | } 159 | 160 | GEN_TEXTSENSORS_SCHEMA = { 161 | cv.Optional(key, default=key): cv.maybe_simple_value( 162 | text_sensor.text_sensor_schema(), key=CONF_NAME, accuracy_decimals=2 ) 163 | for key, value in textSensors.items() 164 | } 165 | 166 | GEN_BINARYSENSORS_SCHEMA = { 167 | cv.Optional(key, default=key): cv.maybe_simple_value( 168 | binary_sensor.binary_sensor_schema(), key=CONF_NAME) 169 | for key, value in binarySensors.items() 170 | } 171 | 172 | CONFIG_SCHEMA = cv.All( 173 | cv.Schema({ 174 | cv.GenerateID(): cv.declare_id(Comfoair), 175 | cv.Optional(CONF_RX_PIN, default=21): pins.internal_gpio_input_pin_number, 176 | cv.Optional(CONF_TX_PIN, default=25): pins.internal_gpio_output_pin_number, 177 | cv.Optional(CONF_GLOBAL_FILTERS): sensor.validate_filters, 178 | }) 179 | .extend(GEN_SENSORS_SCHEMA) 180 | .extend(GEN_TEXTSENSORS_SCHEMA) 181 | .extend(GEN_BINARYSENSORS_SCHEMA) 182 | .extend(cv.COMPONENT_SCHEMA), 183 | ) 184 | 185 | 186 | 187 | async def to_code(config): 188 | #cg.add_library("SPI", "2.0.0") 189 | cg.add_library("can_common", None, "https://github.com/collin80/can_common.git#8585f9dc807ebbeedeb509d74159f40f538d2d65") 190 | cg.add_library("esp32_can", None, "https://github.com/collin80/esp32_can.git#6d835ae82d46748e8267b350680c851a46b38eea") 191 | 192 | 193 | var = cg.new_Pvariable(config[CONF_ID]) 194 | await cg.register_component(var, config) 195 | cg.add(var.set_rx(config[CONF_RX_PIN])) 196 | cg.add(var.set_tx(config[CONF_TX_PIN])) 197 | for key, value in sensors.items(): 198 | sens = await sensor.new_sensor(config[key]) 199 | 200 | filterconf = [] 201 | if (config.get(CONF_GLOBAL_FILTERS)): 202 | for filter in config[CONF_GLOBAL_FILTERS]: 203 | tmp = filter.copy() 204 | tmp['type_id'] = ID(tmp['type_id'].id + key, type=tmp['type_id'].type) 205 | filterconf.append(tmp) 206 | # Append a rounding filter honoring the sensor's accuracy_decimals 207 | #decimals = config[key].get('accuracy_decimals', 0) 208 | #round_cls = cg.esphome_ns.namespace('sensor').class_('RoundFilter') 209 | #round_filter = cg.new_Pvariable(round_cls, 1) # 1 = round to 1 decimal; swap with var or config value 210 | #filterconf.append({'round': 2, 'type_id': ID(f"{key}_round", type=round_cls)}) 211 | 212 | filters = await sensor.build_filters(filterconf) 213 | cg.add(sens.add_filters(filters)) 214 | 215 | cg.add(var.register_sensor(sens, key, value['PDO'], value['CONV'].value, value['div'] if 'div' in value else 1)) 216 | 217 | for key, value in textSensors.items(): 218 | sens = await text_sensor.new_text_sensor(config[key]) 219 | lamda = cppg.RawExpression(f''' 220 | [](uint8_t *vals) -> std::string {{ 221 | {value['code']} 222 | }} 223 | ''') 224 | cg.add(var.register_textSensor(sens, key, value['PDO'], lamda)) 225 | 226 | for key, value in binarySensors.items(): 227 | sens = await binary_sensor.new_binary_sensor(config[key]) 228 | lamda = cppg.RawExpression(f''' 229 | [](uint8_t *vals) -> bool {{ 230 | {value['code']} 231 | }} 232 | ''') 233 | cg.add(var.register_binarySensor(sens, key, value['PDO'], lamda)) 234 | 235 | #cg.add(cg.App.register_climate(var)) 236 | -------------------------------------------------------------------------------- /components/comfoair/comfoair.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | #include "esphome/components/api/custom_api_device.h" 5 | #include "esphome/core/component.h" 6 | #include "esphome/components/sensor/sensor.h" 7 | #include "esphome/components/binary_sensor/binary_sensor.h" 8 | #include "esphome/components/text_sensor/text_sensor.h" 9 | #include "esphome/components/climate/climate.h" 10 | #include "esphome/components/climate/climate_mode.h" 11 | #include "esphome/components/climate/climate_traits.h" 12 | #include "CanAddress.h" 13 | #include "commands.h" 14 | #define amin(a,b) ((a) < (b) ? (a): (b)) 15 | 16 | namespace esphome { 17 | namespace comfoair { 18 | 19 | static const char *TAG = "comfoair.component"; 20 | 21 | template 22 | class ComfoSensor { 23 | public: 24 | T* sensor; 25 | float divider; 26 | CONV conversion; 27 | }; 28 | 29 | class Comfoair: public Component, public climate::Climate, public esphome::api::CustomAPIDevice { 30 | 31 | public: 32 | void set_rx(int rx) { rx_ = rx; } 33 | void set_tx(int tx) { tx_ = tx; } 34 | void register_sensor(sensor::Sensor *obj, std::string key, int PDOID, int conversionType, int divider) { 35 | ComfoSensor *cs = new ComfoSensor(); 36 | cs->sensor = obj; 37 | cs->divider = divider; 38 | cs->conversion = conversionType; 39 | sensors[PDOID] = *cs; 40 | this->PDOs.push_back(PDOID); 41 | this->PDOsMap[PDOID] = key; 42 | } 43 | void register_textSensor(text_sensor::TextSensor *obj, std::string key, int PDOID, std::string (*convLambda)(uint8_t *) ) { 44 | ComfoSensor *cs = new ComfoSensor(); 45 | cs->sensor = obj; 46 | cs->conversion = convLambda; 47 | textSensors[PDOID] = *cs; 48 | this->PDOs.push_back(PDOID); 49 | this->PDOsMap[PDOID] = key; 50 | } 51 | void register_binarySensor(binary_sensor::BinarySensor *obj, std::string key, int PDOID, bool (*convLambda)(uint8_t *) ) { 52 | ComfoSensor *cs = new ComfoSensor(); 53 | cs->sensor = obj; 54 | cs->conversion = convLambda; 55 | binarySensors[PDOID] = *cs; 56 | this->PDOs.push_back(PDOID); 57 | this->PDOsMap[PDOID] = key; 58 | } 59 | /** 60 | * Send a command to the ComfoAir 61 | * @param command The command to send 62 | */ 63 | void send_command(std::string command) { 64 | #define CMDIF(name) if (command == #name) { \ 65 | this->sendVector(new std::vector( CMD_ ## name )); \ 66 | delay(1000); \ 67 | this->sendVector(new std::vector( CMD_ ## name )); \ 68 | } else 69 | CMDIF(ventilation_level_0) 70 | CMDIF(ventilation_level_1) 71 | CMDIF(ventilation_level_2) 72 | CMDIF(ventilation_level_3) 73 | CMDIF(boost_10_min) 74 | CMDIF(boost_20_min) 75 | CMDIF(boost_30_min) 76 | CMDIF(boost_60_min) 77 | CMDIF(boost_end) 78 | CMDIF(auto) 79 | CMDIF(manual) 80 | CMDIF(bypass_activate_1h) 81 | CMDIF(bypass_deactivate_1h) 82 | CMDIF(bypass_auto) 83 | CMDIF(ventilation_supply_only) 84 | CMDIF(ventilation_supply_only_reset) 85 | CMDIF(ventilation_extract_only) 86 | CMDIF(ventilation_extract_only_reset) 87 | CMDIF(temp_profile_normal) 88 | CMDIF(temp_profile_cool) 89 | CMDIF(temp_profile_warm) 90 | ; 91 | } 92 | void sendHex(std::string hexSequenceToSend) { 93 | std::vector bytes; 94 | for (unsigned int i = 0; i < hexSequenceToSend.length(); i += 2) { 95 | std::string byteString = hexSequenceToSend.substr(i, 2); 96 | uint8_t byte = (uint8_t) strtol(byteString.c_str(), NULL, 16); 97 | bytes.push_back(byte); 98 | } 99 | this->sendRaw(bytes.size(), bytes.data()); 100 | } 101 | void sendVector(std::vector *data) { 102 | this->sendRaw(data->size(), data->data()); 103 | } 104 | void sendRaw(uint8_t length, uint8_t *buf){ 105 | sequence++; 106 | sequence = sequence & 0x3; 107 | CAN_FRAME message; 108 | message.extended = true; 109 | message.rtr = 0; 110 | 111 | if (length > 8) { 112 | CanAddress addr = CanAddress(0x3a, 0x1, 0, 1, 0, 1, this->sequence); 113 | uint8_t dataGrams = length / 7; 114 | if (dataGrams * 7 == length) { 115 | dataGrams--; 116 | } 117 | for (uint8_t i = 0; i < dataGrams; i++) { 118 | memset(message.data.byte, 0, 8); 119 | message.data.uint8[0] = i; 120 | message.length = amin((i*7)+7, length) - i*7 + 1; 121 | 122 | message.id = addr.canID(); 123 | memcpy(& message.data.uint8[1], &buf[i * 7], message.length - 1); 124 | CAN0.sendFrame(message); 125 | } 126 | // Send last packet 127 | memset(message.data.uint8, 0, 8); 128 | message.data.uint8[0] = dataGrams | 0x80; 129 | message.length = length - dataGrams * 7 + 1; 130 | memcpy(& message.data.uint8[1], &buf[dataGrams * 7], length - dataGrams * 7); 131 | CAN0.sendFrame(message); 132 | 133 | } else { 134 | CanAddress addr = CanAddress(0x3a, 0x1, 0, 0, 0, 1, this->sequence); 135 | message.id = addr.canID(); 136 | message.length = length; 137 | memcpy(message.data.uint8, buf, length); 138 | CAN0.sendFrame(message); 139 | 140 | } 141 | } 142 | 143 | void setup() override{ 144 | CAN0.setCANPins( (gpio_num_t) this->rx_, (gpio_num_t) this->tx_); 145 | CAN0.begin(50000); 146 | CAN0.watchFor(); 147 | register_service(&Comfoair::send_command, "send_command", {"command"}); 148 | register_service(&Comfoair::sendHex, "send_hex", {"hexSequence"}); 149 | register_service(&Comfoair::req_update_service, "req_update_service", {"PDOID"}); 150 | register_service(&Comfoair::update_next, "update_all", {}); 151 | this->update_next(); 152 | } 153 | 154 | void update_next() { 155 | static size_t to_update = 0; 156 | 157 | if (to_update >= PDOs.size()) { 158 | to_update = 0; 159 | return; 160 | } 161 | 162 | int currentPDO = PDOs[to_update]; 163 | // You can use currentPDO here for whatever operation you want to perform 164 | this->request_data(currentPDO); 165 | to_update++; 166 | ESP_LOGD(TAG, "update_next %d - iterator %d", currentPDO, to_update); 167 | set_timeout("update_next", 1000, [this](){this->update_next();}); 168 | } 169 | void req_update_service(float pdo){ 170 | this->request_data((int)pdo); 171 | } 172 | void request_data(int PDOID) { 173 | // CanAddress addr = CanAddress(0x3, 0x1, 0, 0, 0, 1, this->sequence); 174 | CAN_FRAME message; 175 | message.extended = true; 176 | message.rtr = 1; 177 | message.id = (PDOID << 14) + 0x40 + 0x3a; 178 | 179 | message.length = 0; 180 | CAN0.sendFrame(message); 181 | } 182 | 183 | void loop(){ 184 | // ESP_LOGD(TAG, "loop"); 185 | if (CAN0.read(canMessage)) { 186 | uint16_t PDOID = (canMessage.id & 0x01fff000) >> 14; 187 | uint8_t *vals = &canMessage.data.uint8[0]; 188 | if (sensors.find(PDOID) != sensors.end()) { 189 | ComfoSensor el = sensors.find(PDOID)->second; 190 | float sensorVal; 191 | 192 | switch (el.conversion) { 193 | case 0: // UINT8 194 | sensorVal = vals[0]; 195 | break; 196 | case 1: // UINT16 197 | sensorVal = (vals[0] + (vals[1]<<8)); 198 | break; 199 | case 2: // UINT32 200 | sensorVal = (vals[0] + (vals[1]<<8) + ((vals[2] + (vals[3]<<8))<<16)); 201 | break; 202 | case 3: // INT16 203 | sensorVal = ((vals[1] < 0x80 ? (vals[0] + (vals[1] << 8)) : - ((vals[0] ^ 0xFF) + ((vals[1] ^ 0xFF) << 8) + 1))); 204 | break; 205 | } 206 | if (el.divider > 1) { 207 | sensorVal = sensorVal / el.divider; 208 | } 209 | el.sensor->publish_state(sensorVal); 210 | maybeUpdateClimate(PDOID, sensorVal); 211 | } else if (textSensors.find(PDOID) != textSensors.end()) { 212 | ComfoSensor el = textSensors.find(PDOID)->second; 213 | el.sensor->publish_state(el.conversion(vals)); 214 | maybeUpdateClimate(PDOID, el.conversion(vals)); 215 | }else if (binarySensors.find(PDOID) != binarySensors.end()) { 216 | ComfoSensor el = binarySensors.find(PDOID)->second; 217 | el.sensor->publish_state(el.conversion(vals)); 218 | } 219 | } 220 | 221 | } 222 | 223 | void dump_config(){ 224 | } 225 | 226 | void maybeUpdateClimate(int PDO, std::string newVal) { 227 | std::string sensorName = this->PDOsMap[PDO]; 228 | if ("temp_profile" == sensorName) { 229 | if (newVal == "auto") { 230 | this->mode = climate::CLIMATE_MODE_AUTO; 231 | } else if (newVal == "cold") { 232 | this->mode = climate::CLIMATE_MODE_COOL; 233 | } else if (newVal == "warm") { 234 | this->mode = climate::CLIMATE_MODE_HEAT; 235 | } 236 | this->publish_state(); 237 | } 238 | } 239 | 240 | void maybeUpdateClimate(int PDO, float newVal){ 241 | std::string sensorName = this->PDOsMap[PDO]; 242 | // ESP_LOGD(TAG, "maybeUpdateClimate %s %f", sensorName.c_str(), newVal); 243 | if ("fan_speed" == sensorName) { 244 | ESP_LOGD(TAG, "ibua %s %f", sensorName.c_str(), newVal); 245 | if (newVal == 0) { 246 | this->fan_mode = (climate::CLIMATE_FAN_OFF); 247 | } else if (newVal == 1) { 248 | this->fan_mode = (climate::CLIMATE_FAN_LOW); 249 | } else if (newVal == 2) { 250 | this->fan_mode = (climate::CLIMATE_FAN_MEDIUM); 251 | } else if (newVal == 3) { 252 | this->fan_mode = (climate::CLIMATE_FAN_HIGH); 253 | } 254 | this->publish_state(); 255 | } else if ("extract_air_temp" == sensorName) { 256 | this->current_temperature = newVal; 257 | this->publish_state(); 258 | } else if ("extract_air_humidity" == sensorName) { 259 | this->current_humidity = newVal; 260 | this->publish_state(); 261 | } else if ("target_temp" == sensorName) { 262 | this->target_temperature = newVal; 263 | this->publish_state(); 264 | } 265 | } 266 | 267 | climate::ClimateTraits traits() override { 268 | auto traits = climate::ClimateTraits(); 269 | traits.set_supports_current_temperature(true); 270 | traits.set_supports_current_humidity(true); 271 | traits.set_supports_two_point_target_temperature(false); 272 | traits.set_supports_action(false); 273 | traits.set_supported_modes({climate::CLIMATE_MODE_AUTO, climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_HEAT}); 274 | traits.set_supported_presets({climate::CLIMATE_PRESET_HOME}); 275 | traits.set_visual_min_temperature(10); 276 | traits.set_visual_max_temperature(35); 277 | traits.set_visual_temperature_step(1); 278 | traits.set_supported_fan_modes({ 279 | climate::CLIMATE_FAN_AUTO, 280 | climate::CLIMATE_FAN_LOW, 281 | climate::CLIMATE_FAN_MEDIUM, 282 | climate::CLIMATE_FAN_HIGH, 283 | climate::CLIMATE_FAN_OFF 284 | }); 285 | return traits; 286 | } 287 | 288 | void control(const climate::ClimateCall &instruction) override { 289 | if (instruction.get_fan_mode().has_value()){ 290 | switch (instruction.get_fan_mode().value()) { 291 | case climate::CLIMATE_FAN_AUTO: 292 | this->send_command("auto"); 293 | break; 294 | case climate::CLIMATE_FAN_LOW: 295 | this->send_command("ventilation_level_1"); 296 | break; 297 | case climate::CLIMATE_FAN_MEDIUM: 298 | this->send_command("ventilation_level_2"); 299 | break; 300 | case climate::CLIMATE_FAN_HIGH: 301 | this->send_command("ventilation_level_3"); 302 | break; 303 | case climate::CLIMATE_FAN_OFF: 304 | this->send_command("ventilation_level_0"); 305 | break; 306 | } 307 | } 308 | if (instruction.get_mode().has_value()){ 309 | switch (instruction.get_mode().value()) { 310 | case climate::CLIMATE_MODE_AUTO: 311 | this->send_command("temp_profile_normal"); 312 | break; 313 | case climate::CLIMATE_MODE_COOL: 314 | this->send_command("temp_profile_cool"); 315 | break; 316 | case climate::CLIMATE_MODE_HEAT: 317 | this->send_command("temp_profile_warm"); 318 | break; 319 | } 320 | 321 | } 322 | } 323 | 324 | private: 325 | CAN_FRAME canMessage; 326 | 327 | protected: 328 | int rx_{-1}; 329 | int tx_{-1}; 330 | uint8_t sequence = 0; 331 | /* List of PDOs to request */ 332 | std::vector PDOs; 333 | /* map between pdoid and string key */ 334 | std::map PDOsMap; 335 | std::map> sensors; 336 | std::map> textSensors; 337 | std::map> binarySensors; 338 | 339 | }; 340 | 341 | } //namespace comfoair 342 | } //namespace esphome 343 | -------------------------------------------------------------------------------- /components/comfoair/commands.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #define CMD_ventilation_level_0 { 0x84, 0x15, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 } 4 | #define CMD_ventilation_level_1 { 0x84, 0x15, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01 } 5 | #define CMD_ventilation_level_2 { 0x84, 0x15, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02 } 6 | #define CMD_ventilation_level_3 { 0x84, 0x15, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03 } 7 | #define CMD_boost_10_min { 0x84, 0x15, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x58, 0x02, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00 } 8 | #define CMD_boost_20_min { 0x84, 0x15, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0xB0, 0x04, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00 } 9 | #define CMD_boost_30_min { 0x84, 0x15, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x08, 0x07, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00 } 10 | #define CMD_boost_60_min { 0x84, 0x15, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x10, 0x0E, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00 } 11 | #define CMD_boost_end { 0x85, 0x15, 0x01, 0x06 } 12 | #define CMD_auto { 0x85, 0x15, 0x08, 0x01 } 13 | #define CMD_manual { 0x84, 0x15, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01 } 14 | #define CMD_bypass_activate_1h { 0x84, 0x15, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10, 0x0e, 0x00, 0x00, 0x01 } 15 | #define CMD_bypass_deactivate_1h { 0x84, 0x15, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10, 0x0e, 0x00, 0x00, 0x02 } 16 | #define CMD_bypass_auto { 0x85, 0x15, 0x02, 0x01 } 17 | #define CMD_ventilation_supply_only { 0x84, 0x15, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10, 0x0e, 0x00, 0x00, 0x01 } 18 | #define CMD_ventilation_supply_only_reset { 0x85, 0x15, 0x06, 0x01 } 19 | #define CMD_ventilation_extract_only { 0x84, 0x15, 0x07, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10, 0x0e, 0x00, 0x00, 0x00 } 20 | #define CMD_ventilation_extract_only_reset { 0x85, 0x15, 0x07, 0x01 } 21 | #define CMD_temp_profile_normal { 0x84, 0x15, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00 } 22 | #define CMD_temp_profile_cool { 0x84, 0x15, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01 } 23 | #define CMD_temp_profile_warm { 0x84, 0x15, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x02 } 24 | 25 | -------------------------------------------------------------------------------- /docs/climate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vekexasia/comfoair-esp32/14a8acb92c1695a021ce39e288c969e2d4619d98/docs/climate.png -------------------------------------------------------------------------------- /docs/esphome_sample.yaml: -------------------------------------------------------------------------------- 1 | external_components: 2 | - source: github://vekexasia/comfoair-esp32 3 | components: [ comfoair ] 4 | 5 | comfoair: 6 | id: comfo 7 | rx_pin: GPIO5 8 | tx_pin: GPIO4 9 | 10 | esphome: 11 | name: comfoair 12 | friendly_name: comfoair 13 | 14 | esp32: 15 | #board: esp32dev 16 | board: az-delivery-devkit-v4 17 | framework: 18 | type: arduino 19 | 20 | #platform = espressif32 21 | 22 | 23 | # Enable logging 24 | logger: 25 | 26 | # Enable Home Assistant API 27 | api: 28 | encryption: 29 | key: "YOUR_KEY_GOES_HERE" 30 | 31 | ota: 32 | password: "madrulez" 33 | 34 | wifi: 35 | ssid: !secret wifi_ssid 36 | password: !secret wifi_password 37 | 38 | # Enable fallback hotspot (captive portal) in case wifi connection fails 39 | ap: 40 | ssid: "Comfoair Fallback Hotspot" 41 | password: "madrulez" 42 | 43 | # see: https://esphome.io/components/time.html 44 | time: 45 | - platform: homeassistant 46 | id: homeassistant_time 47 | 48 | # Enable Web server 49 | web_server: 50 | port: 80 51 | 52 | button: 53 | - platform: template 54 | name: "Boost 10m" 55 | id: comfoair_boost_10min 56 | on_press: 57 | then: 58 | - lambda: |- 59 | id(comfo).send_command("boost_10_min"); 60 | 61 | select: 62 | - platform: template 63 | name: "Belüftungsstärke" 64 | id: comfoair_ventilation_level 65 | optimistic: true 66 | options: 67 | - "Abwesend" 68 | - "Stufe 1" 69 | - "Stufe 2" 70 | - "Stufe 3" 71 | set_action: 72 | - lambda: |- 73 | if (x == "Stufe 1") { 74 | id(comfo).send_command("ventilation_level_1"); 75 | } 76 | if (x == "Stufe 2") { 77 | id(comfo).send_command("ventilation_level_2"); 78 | } 79 | if (x == "Stufe 3") { 80 | id(comfo).send_command("ventilation_level_3"); 81 | } 82 | if (x == "Abwesend") { 83 | id(comfo).send_command("ventilation_level_0"); 84 | } 85 | - platform: template 86 | name: "Mode" 87 | id: comfoair_mode 88 | optimistic: true 89 | options: 90 | - "auto" 91 | - "manual" 92 | set_action: 93 | - lambda: |- 94 | if (x == "auto") { 95 | id(comfo).send_command("auto"); 96 | } 97 | if (x == "manual") { 98 | id(comfo).send_command("manual"); 99 | } 100 | -------------------------------------------------------------------------------- /docs/homeassistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vekexasia/comfoair-esp32/14a8acb92c1695a021ce39e288c969e2d4619d98/docs/homeassistant.png -------------------------------------------------------------------------------- /docs/pcb/BOM_PCB_comfoair_2_2023-04-28.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vekexasia/comfoair-esp32/14a8acb92c1695a021ce39e288c969e2d4619d98/docs/pcb/BOM_PCB_comfoair_2_2023-04-28.csv -------------------------------------------------------------------------------- /docs/pcb/Gerber_PCB_comfoair_2_2023-04-28.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vekexasia/comfoair-esp32/14a8acb92c1695a021ce39e288c969e2d4619d98/docs/pcb/Gerber_PCB_comfoair_2_2023-04-28.zip -------------------------------------------------------------------------------- /docs/pic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vekexasia/comfoair-esp32/14a8acb92c1695a021ce39e288c969e2d4619d98/docs/pic.jpg --------------------------------------------------------------------------------