├── .gitignore ├── esphome └── components │ └── prana_ble │ ├── prana_ble_child.h │ ├── number │ ├── prana_ble_number.h │ ├── __init__.py │ └── prana_ble_number.cpp │ ├── select │ ├── prana_ble_fan_select.h │ ├── prana_ble_display_select.h │ ├── prana_ble_fan_select.cpp │ ├── prana_ble_display_select.cpp │ └── __init__.py │ ├── switch │ ├── prana_ble_switch.h │ ├── prana_ble_switch.cpp │ └── __init__.py │ ├── fan │ ├── prana_ble_fan.h │ ├── prana_ble_fan.cpp │ └── __init__.py │ ├── __init__.py │ ├── sensor │ ├── prana_ble_sensors.h │ ├── prana_ble_sensors.cpp │ └── __init__.py │ ├── prana_ble_const.h │ ├── prana_ble_hub.h │ └── prana_ble_hub.cpp ├── README.md └── prana_conf_example.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode -------------------------------------------------------------------------------- /esphome/components/prana_ble/prana_ble_child.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/helpers.h" 4 | #include "prana_ble_hub.h" 5 | #include "prana_ble_const.h" 6 | namespace esphome { 7 | namespace prana_ble { 8 | 9 | // Forward declare PranaBLEHub 10 | class PranaBLEHub; 11 | 12 | class PranaBLEClient : public Parented { 13 | public: 14 | virtual void on_status(const PranaStatusPacket *data) = 0; 15 | virtual void on_prana_state(bool is_ready) = 0; 16 | 17 | protected: 18 | friend PranaBLEHub; 19 | virtual std::string describe() = 0; 20 | }; 21 | 22 | } // namespace prana_ble 23 | } // namespace esphome 24 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/number/prana_ble_number.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/components/number/number.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/core/optional.h" 6 | #include "../prana_ble_child.h" 7 | #include "../prana_ble_const.h" 8 | 9 | namespace esphome { 10 | namespace prana_ble { 11 | 12 | class PranaBLENumber : public number::Number, public PranaBLEClient, public Component { 13 | public: 14 | void dump_config() override; 15 | float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } 16 | 17 | /* PranaClient status update */ 18 | void on_status(const PranaStatusPacket *data) override; 19 | void on_prana_state(bool is_ready) override{}; 20 | std::string describe() override; 21 | 22 | protected: 23 | void control(float value) override; 24 | bool restore_value_{false}; 25 | }; 26 | 27 | } // namespace prana_ble 28 | } // namespace esphome -------------------------------------------------------------------------------- /esphome/components/prana_ble/select/prana_ble_fan_select.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/components/select/select.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/core/optional.h" 6 | #include "../prana_ble_child.h" 7 | #include "../prana_ble_const.h" 8 | #include "../prana_ble_hub.h" 9 | 10 | namespace esphome { 11 | namespace prana_ble { 12 | 13 | class PranaBLEFanSelect : public select::Select, public PranaBLEClient, public Component { 14 | public: 15 | void dump_config() override; 16 | float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } 17 | 18 | /* PranaClient status update */ 19 | void on_status(const PranaStatusPacket *data) override; 20 | void on_prana_state(bool is_ready) override{}; 21 | std::string describe() override; 22 | 23 | protected: 24 | void control(const std::string &value) override; 25 | }; 26 | 27 | } // namespace prana_ble 28 | } // namespace esphome -------------------------------------------------------------------------------- /esphome/components/prana_ble/select/prana_ble_display_select.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/components/select/select.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/core/optional.h" 6 | #include "../prana_ble_child.h" 7 | #include "../prana_ble_const.h" 8 | #include "../prana_ble_hub.h" 9 | 10 | namespace esphome { 11 | namespace prana_ble { 12 | 13 | class PranaBLEDisplaySelect : public select::Select, public PranaBLEClient, public Component { 14 | public: 15 | void dump_config() override; 16 | float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } 17 | 18 | /* PranaClient status update */ 19 | void on_status(const PranaStatusPacket *data) override; 20 | void on_prana_state(bool is_ready) override{}; 21 | std::string describe() override; 22 | 23 | protected: 24 | void control(const std::string &value) override; 25 | }; 26 | 27 | } // namespace prana_ble 28 | } // namespace esphome -------------------------------------------------------------------------------- /esphome/components/prana_ble/number/__init__.py: -------------------------------------------------------------------------------- 1 | from esphome.components import number 2 | import esphome.config_validation as cv 3 | import esphome.codegen as cg 4 | from esphome.const import ( 5 | CONF_ID, 6 | ) 7 | 8 | from .. import ( 9 | PRANA_BLE_CLIENT_SCHEMA, 10 | prana_ble_ns, 11 | register_prana_child, 12 | ) 13 | 14 | DEPENDENCIES = ["prana_ble"] 15 | CODEOWNERS = ["@voed"] 16 | 17 | ICON_BRIGHTNESS = "mdi:television-ambient-light" 18 | 19 | PranaBLENumber = prana_ble_ns.class_("PranaBLENumber", number.Number, cg.Component) 20 | 21 | 22 | CONFIG_SCHEMA = ( 23 | number.number_schema( 24 | PranaBLENumber, 25 | icon=ICON_BRIGHTNESS 26 | ) 27 | .extend(cv.COMPONENT_SCHEMA) 28 | .extend(PRANA_BLE_CLIENT_SCHEMA) 29 | ) 30 | 31 | 32 | async def to_code(config): 33 | var = await number.new_number( 34 | config, 35 | min_value=1, 36 | max_value=6, 37 | step=1, 38 | ) 39 | await cg.register_component(var, config) 40 | await register_prana_child(var, config) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 |

6 | 7 | # ESPHome component for Prana recuperators. 8 | 9 | This is unofficial component to connect your Prana recuperator with Home Assistant via Bluetooth. 10 | 11 | This project is still work in progress, if you have any problems with it, feel free to open an issue. 12 | 13 | What is working for now: 14 | - Controlling fans speed 15 | - All sensors that your device may have: voltage, frequency, temperatures, humidity, pressure, TVOC, CO2 16 | - Turn on/off, winter mode, heater switches 17 | - Set current display mode for recuperator's display 18 | 19 | 20 | See [example config](https://github.com/voed/esphome_prana_ble/blob/master/prana_conf_example.yaml) to create your own configuration. 21 | 22 | Join [Telegram chat](https://t.me/esphome_prana) for discussions. 23 | 24 | If you want to support this project, you can do a donation [via Monobank](https://send.monobank.ua/jar/Lw9tQQ2XL) or Paypal `voed@i.ua` 25 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/number/prana_ble_number.cpp: -------------------------------------------------------------------------------- 1 | #include "esphome/core/log.h" 2 | #include "prana_ble_number.h" 3 | 4 | namespace esphome { 5 | namespace prana_ble { 6 | 7 | void PranaBLENumber::control(float value) { 8 | ESP_LOGV(TAG, "Setting number: %f", value); 9 | if (!this->parent_->is_connected()) { 10 | ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); 11 | return; 12 | } 13 | short ival = round(value); 14 | if(parent_->get_brightness() != ival) 15 | { 16 | ESP_LOGW(TAG, "Setting number: %f %i", value, ival); 17 | parent_->set_brightness(ival); 18 | this->publish_state(value); 19 | } 20 | 21 | 22 | } 23 | 24 | 25 | void PranaBLENumber::on_status(const PranaStatusPacket *data) { 26 | if(data == nullptr) 27 | return; 28 | 29 | this->publish_state(parent_->get_brightness()); 30 | } 31 | void PranaBLENumber::dump_config() { 32 | LOG_NUMBER("", "PranaBLE Number", this); 33 | } 34 | std::string PranaBLENumber::describe() { return "Prana BLE Number"; } 35 | } // namespace prana_ble 36 | } // namespace esphome -------------------------------------------------------------------------------- /esphome/components/prana_ble/switch/prana_ble_switch.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/core/defines.h" 5 | #include "esphome/core/hal.h" 6 | #include "esphome/components/switch/switch.h" 7 | #include "../prana_ble_child.h" 8 | #include "../prana_ble_const.h" 9 | 10 | 11 | #ifdef USE_ESP32 12 | 13 | namespace esphome { 14 | namespace prana_ble { 15 | 16 | enum PranaSwitchType 17 | { 18 | ENABLE, 19 | HEAT, 20 | WINTER, 21 | CONNECT, 22 | FAN_LOCK 23 | }; 24 | 25 | class PranaBLESwitch : public switch_::Switch, public PranaBLEClient, public Component { 26 | public: 27 | void dump_config() override; 28 | float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } 29 | 30 | /* PranaClient status update */ 31 | void on_status(const PranaStatusPacket *data) override; 32 | void on_prana_state(bool is_ready) override{}; 33 | std::string describe() override; 34 | void set_switch_type(PranaSwitchType type) { switch_type_ = type;} 35 | protected: 36 | void write_state(bool state) override; 37 | 38 | private: 39 | PranaSwitchType switch_type_; 40 | }; 41 | 42 | } // namespace prana_ble 43 | } // namespace esphome 44 | 45 | #endif 46 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/fan/prana_ble_fan.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/core/defines.h" 5 | #include "esphome/core/hal.h" 6 | #include "esphome/components/fan/fan.h" 7 | #include "../prana_ble_child.h" 8 | #include "../prana_ble_const.h" 9 | #include "../prana_ble_hub.h" 10 | 11 | #ifdef USE_ESP32 12 | 13 | namespace esphome { 14 | namespace prana_ble { 15 | 16 | class PranaBLEFan : public fan::Fan, public PranaBLEClient, public Component { 17 | public: 18 | void setup() override; 19 | void dump_config() override; 20 | float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } 21 | 22 | /* PranaClient status update */ 23 | void on_status(const PranaStatusPacket *data) override; 24 | void on_prana_state(bool is_ready) override{}; 25 | std::string describe() override; 26 | 27 | fan::FanTraits get_traits() override { return this->traits_; } 28 | 29 | void set_fan_type(PranaFan fan_type) { fan_type_ = fan_type; } 30 | void set_speed_count(int8_t count) { speed_count_ = count; } 31 | void set_fan_direct(bool direct) { direct_ = direct; } 32 | protected: 33 | void control(const fan::FanCall &call) override; 34 | PranaFanMode fan_mode; 35 | fan::FanTraits traits_; 36 | PranaFan fan_type_; 37 | bool direct_; 38 | int8_t speed_count_{10}; 39 | }; 40 | 41 | } // namespace prana_ble 42 | } // namespace esphome 43 | 44 | #endif 45 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/select/prana_ble_fan_select.cpp: -------------------------------------------------------------------------------- 1 | #include "esphome/core/log.h" 2 | #include "prana_ble_fan_select.h" 3 | 4 | namespace esphome { 5 | namespace prana_ble { 6 | 7 | 8 | PranaFanMode get_mode_from_string(const std::string& mode_string) 9 | { 10 | for(int i=0; i < PRANA_FAN_MODES.size(); i++) 11 | { 12 | if(PRANA_FAN_MODES[i] == mode_string) 13 | return static_cast(i); 14 | } 15 | return PranaFanMode::Manual; 16 | } 17 | 18 | void PranaBLEFanSelect::control(const std::string &value) { 19 | if (!this->parent_->is_connected()) { 20 | ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); 21 | return; 22 | } 23 | auto ival = get_mode_from_string(value); 24 | if(parent_->get_display_mode() != ival) 25 | { 26 | ESP_LOGW(TAG, "Setting value: %s %i", value.c_str(), ival); 27 | parent_->set_auto_mode(ival); 28 | this->publish_state(value); 29 | } 30 | } 31 | 32 | void PranaBLEFanSelect::on_status(const PranaStatusPacket *data) { 33 | if(data == nullptr) 34 | return; 35 | auto mode = data->fan_mode; 36 | if(mode > PRANA_FAN_MODES.size()) 37 | { 38 | ESP_LOGE(TAG, "Incorrect fan mode: %i", mode); 39 | return; 40 | } 41 | 42 | this->publish_state(PRANA_FAN_MODES[mode]); 43 | } 44 | void PranaBLEFanSelect::dump_config() { 45 | LOG_SELECT("", describe().c_str(), this); 46 | } 47 | std::string PranaBLEFanSelect::describe() { return "Prana BLE Select Fan"; } 48 | } // namespace prana_ble 49 | } // namespace esphome -------------------------------------------------------------------------------- /esphome/components/prana_ble/select/prana_ble_display_select.cpp: -------------------------------------------------------------------------------- 1 | #include "esphome/core/log.h" 2 | #include "prana_ble_display_select.h" 3 | 4 | namespace esphome { 5 | namespace prana_ble { 6 | 7 | 8 | PranaDisplayMode get_display_mode_from_string(const std::string& mode_string) 9 | { 10 | for(int i=0; i < PRANA_DISPLAY_MODES.size(); i++) 11 | { 12 | if(PRANA_DISPLAY_MODES[i] == mode_string) 13 | return static_cast(i); 14 | } 15 | return PranaDisplayMode::FAN; 16 | } 17 | 18 | void PranaBLEDisplaySelect::control(const std::string &value) { 19 | if (!this->parent_->is_connected()) { 20 | ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); 21 | return; 22 | } 23 | short ival = get_display_mode_from_string(value); 24 | if(parent_->get_display_mode() != ival) 25 | { 26 | ESP_LOGW(TAG, "Setting value: %s %i", value.c_str(), ival); 27 | parent_->set_display_mode(ival); 28 | this->publish_state(value); 29 | } 30 | } 31 | 32 | 33 | void PranaBLEDisplaySelect::on_status(const PranaStatusPacket *data) { 34 | if(data == nullptr) 35 | return; 36 | if(data->display_mode > PRANA_DISPLAY_MODES.size()) 37 | { 38 | ESP_LOGE(TAG, "Incorrect display mode: %i", data->display_mode); 39 | 40 | return; 41 | } 42 | 43 | this->publish_state(PRANA_DISPLAY_MODES[data->display_mode]); 44 | } 45 | void PranaBLEDisplaySelect::dump_config() { 46 | LOG_SELECT("", describe().c_str(), this); 47 | } 48 | std::string PranaBLEDisplaySelect::describe() { return "Prana BLE Select Display"; } 49 | } // namespace prana_ble 50 | } // namespace esphome -------------------------------------------------------------------------------- /esphome/components/prana_ble/select/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg # type: ignore 2 | from esphome.components import select 3 | import esphome.config_validation as cv 4 | from .. import ( 5 | PRANA_BLE_CLIENT_SCHEMA, 6 | prana_ble_ns, 7 | register_prana_child, 8 | ) 9 | 10 | DEPENDENCIES = ["prana_ble"] 11 | CODEOWNERS = ["@voed"] 12 | 13 | CONF_DISPLAY_MODE = "display_mode" 14 | CONF_FAN_MODE = "fan_mode" 15 | 16 | ICON_DISPLAY = "mdi:unfold-more-vertical" 17 | ICON_FAN_AUTO="mdi:fan-auto" 18 | 19 | PranaBLEDisplaySelect = prana_ble_ns.class_("PranaBLEDisplaySelect", select.Select, cg.Component) 20 | PranaBLEFanSelect = prana_ble_ns.class_("PranaBLEFanSelect", select.Select, cg.Component) 21 | 22 | CONFIG_SCHEMA = ( 23 | cv.Schema( 24 | { 25 | cv.Optional(CONF_DISPLAY_MODE): select.select_schema( 26 | PranaBLEDisplaySelect, 27 | icon=ICON_DISPLAY 28 | ), 29 | cv.Optional(CONF_FAN_MODE): select.select_schema( 30 | PranaBLEFanSelect, 31 | icon=ICON_FAN_AUTO 32 | ), 33 | } 34 | ) 35 | .extend(cv.COMPONENT_SCHEMA) 36 | .extend(PRANA_BLE_CLIENT_SCHEMA) 37 | ) 38 | 39 | async def to_code(config): 40 | if CONF_DISPLAY_MODE in config: 41 | sel = await select.new_select( 42 | config[CONF_DISPLAY_MODE], 43 | options=["Fan", "Temp inside", "Temp outside", "CO2", "VOC", "Humidity", "Air quality", "Pressure", "Date", "Time"] 44 | ) 45 | await cg.register_component(sel, config) 46 | await register_prana_child(sel, config) 47 | 48 | if CONF_FAN_MODE in config: 49 | sel = await select.new_select( 50 | config[CONF_FAN_MODE], 51 | options=["Manual", "Auto", "Auto+"] 52 | ) 53 | await cg.register_component(sel, config) 54 | await register_prana_child(sel, config) 55 | 56 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | import esphome.config_validation as cv 3 | from esphome.components import ble_client, time 4 | from esphome.const import ( 5 | CONF_ID, 6 | CONF_RECEIVE_TIMEOUT, 7 | CONF_TIME_ID, 8 | CONF_UPDATE_INTERVAL 9 | ) 10 | 11 | CODEOWNERS = ["@voed"] 12 | DEPENDENCIES = ["ble_client"] 13 | MULTI_CONF = True 14 | CONF_PRANA_ID = "prana_ble_id" 15 | 16 | MULTI_CONF = 3 17 | 18 | prana_ble_ns = cg.esphome_ns.namespace("prana_ble") 19 | PranaBLEHub = prana_ble_ns.class_( 20 | "PranaBLEHub", ble_client.BLEClientNode, cg.PollingComponent 21 | ) 22 | 23 | CONFIG_SCHEMA = ( 24 | cv.COMPONENT_SCHEMA.extend( 25 | { 26 | cv.GenerateID(): cv.declare_id(PranaBLEHub), 27 | cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), 28 | cv.Optional( 29 | CONF_RECEIVE_TIMEOUT, default="0s" 30 | ): cv.positive_time_period_milliseconds, 31 | } 32 | ) 33 | .extend(ble_client.BLE_CLIENT_SCHEMA) 34 | .extend(cv.polling_component_schema("1s")) 35 | ) 36 | 37 | PRANA_BLE_CLIENT_SCHEMA = cv.Schema( 38 | { 39 | cv.Required(CONF_PRANA_ID): cv.use_id(PranaBLEHub), 40 | } 41 | ) 42 | 43 | 44 | async def register_prana_child(var, config): 45 | parent = await cg.get_variable(config[CONF_PRANA_ID]) 46 | cg.add(parent.register_child(var)) 47 | 48 | 49 | async def to_code(config): 50 | if config[CONF_UPDATE_INTERVAL] < cv.time_period("0.1s"): 51 | raise cv.Invalid("Update interval must be more than 0.1s") 52 | 53 | var = cg.new_Pvariable(config[CONF_ID]) 54 | await cg.register_component(var, config) 55 | await ble_client.register_ble_node(var, config) 56 | if CONF_TIME_ID in config: 57 | time_ = await cg.get_variable(config[CONF_TIME_ID]) 58 | cg.add(var.set_time_id(time_)) 59 | if CONF_RECEIVE_TIMEOUT in config: 60 | cg.add(var.set_status_timeout(config[CONF_RECEIVE_TIMEOUT])) 61 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/sensor/prana_ble_sensors.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/core/defines.h" 5 | #include "esphome/core/hal.h" 6 | #include "esphome/components/sensor/sensor.h" 7 | #include "../prana_ble_child.h" 8 | #include "../prana_ble_const.h" 9 | 10 | #ifdef USE_ESP32 11 | 12 | namespace esphome { 13 | namespace prana_ble { 14 | 15 | class PranaBLESensors : public PranaBLEClient, public Component { 16 | public: 17 | void dump_config() override; 18 | float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } 19 | 20 | /* PranaClient status update */ 21 | void on_status(const PranaStatusPacket *data) override; 22 | void on_prana_state(bool is_ready) override{}; 23 | std::string describe() override; 24 | 25 | void set_temp_inside_out(sensor::Sensor *temp) { temp_inside_out_ = temp; } 26 | void set_temp_inside_in(sensor::Sensor *temp) { temp_inside_in_ = temp; } 27 | void set_temp_outside_out(sensor::Sensor *temp) { temp_outside_out_ = temp; } 28 | void set_temp_outside_in(sensor::Sensor *temp) { temp_outside_in_ = temp; } 29 | void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } 30 | void set_pressure(sensor::Sensor *pressure) { pressure_ = pressure; } 31 | void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } 32 | void set_co2(sensor::Sensor *co2) { co2_ = co2; } 33 | void set_voltage(sensor::Sensor *voltage) { voltage_ = voltage; } 34 | void set_frequency(sensor::Sensor *frequency) { frequency_ = frequency; } 35 | void set_timestamp(sensor::Sensor *timestamp) { timestamp_ = timestamp; } 36 | 37 | protected: 38 | sensor::Sensor *temp_inside_out_{nullptr}; 39 | sensor::Sensor *temp_inside_in_{nullptr}; 40 | sensor::Sensor *temp_outside_out_{nullptr}; 41 | sensor::Sensor *temp_outside_in_{nullptr}; 42 | sensor::Sensor *humidity_{nullptr}; 43 | sensor::Sensor *pressure_{nullptr}; 44 | sensor::Sensor *tvoc_{nullptr}; 45 | sensor::Sensor *co2_{nullptr}; 46 | sensor::Sensor *voltage_{nullptr}; 47 | sensor::Sensor *frequency_{nullptr}; 48 | sensor::Sensor *timestamp_{nullptr}; 49 | }; 50 | 51 | } // namespace prana_ble 52 | } // namespace esphome 53 | 54 | #endif 55 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/switch/prana_ble_switch.cpp: -------------------------------------------------------------------------------- 1 | #include "prana_ble_switch.h" 2 | #include "esphome/core/log.h" 3 | 4 | #ifdef USE_ESP32 5 | 6 | namespace esphome { 7 | namespace prana_ble { 8 | 9 | 10 | void PranaBLESwitch::dump_config() { LOG_SWITCH("", "Prana BLE Switch", this); } 11 | std::string PranaBLESwitch::describe() { return str_sprintf("Prana BLE Switch %s", this->get_name().c_str()); } 12 | 13 | void PranaBLESwitch::write_state(bool state) { 14 | if (switch_type_ != PranaSwitchType::CONNECT && !this->parent_->is_connected()) { 15 | ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); 16 | return; 17 | } 18 | switch(switch_type_) 19 | { 20 | case PranaSwitchType::ENABLE: 21 | { 22 | if(!state) 23 | this->parent_->command_poweroff(); 24 | else 25 | this->parent_->command_poweron(); 26 | break; 27 | } 28 | 29 | case PranaSwitchType::HEAT: 30 | { 31 | this->parent_->command_heating(); 32 | break; 33 | } 34 | case PranaSwitchType::WINTER: 35 | { 36 | this->parent_->command_winter_mode(); 37 | break; 38 | } 39 | case PranaSwitchType::CONNECT: 40 | { 41 | if(state) 42 | { 43 | this->parent_->command_connect(); 44 | } 45 | else 46 | this->parent_->command_disconnect(); 47 | 48 | this->publish_state(state); 49 | break; 50 | } 51 | case PranaSwitchType::FAN_LOCK: 52 | { 53 | parent_->set_fans_locked(state); 54 | 55 | break; 56 | } 57 | } 58 | } 59 | 60 | void PranaBLESwitch::on_status(const PranaStatusPacket *data) { 61 | if(data == nullptr) 62 | return; 63 | switch(switch_type_) 64 | { 65 | case PranaSwitchType::ENABLE: 66 | { 67 | this->publish_state(data->enabled); 68 | break; 69 | } 70 | 71 | case PranaSwitchType::HEAT: 72 | { 73 | this->publish_state(data->heating_on); 74 | break; 75 | } 76 | case PranaSwitchType::WINTER: 77 | { 78 | this->publish_state(data->winter_mode); 79 | break; 80 | } 81 | case PranaSwitchType::CONNECT: 82 | { 83 | this->publish_state(this->parent_->is_connection_enabled()); 84 | break; 85 | } 86 | 87 | case PranaSwitchType::FAN_LOCK: 88 | { 89 | this->publish_state(this->parent_->get_fans_locked()); 90 | break; 91 | } 92 | } 93 | } 94 | 95 | 96 | } // namespace prana_ble 97 | } // namespace esphome 98 | 99 | #endif 100 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/sensor/prana_ble_sensors.cpp: -------------------------------------------------------------------------------- 1 | #include "prana_ble_sensors.h" 2 | #include "esphome/core/log.h" 3 | 4 | #ifdef USE_ESP32 5 | 6 | namespace esphome { 7 | namespace prana_ble { 8 | 9 | 10 | void PranaBLESensors::dump_config() { ESP_LOGCONFIG("", "Prana BLE Sensors"); } 11 | std::string PranaBLESensors::describe() { return "Prana BLE Sensors"; } 12 | 13 | 14 | bool is_temp_valid(float temp) 15 | { 16 | //TODO: test this 17 | if(abs(temp) > 60.0) 18 | return false; 19 | 20 | return true; 21 | } 22 | 23 | float convert_temp(short temp) 24 | { 25 | return (byteswap(temp) & 0x3fff) / 10.0; 26 | } 27 | 28 | void PranaBLESensors::on_status(const PranaStatusPacket *data) { 29 | if(data == nullptr) 30 | return; 31 | 32 | float temp = 0.0; 33 | if(this->temp_inside_out_ != nullptr) 34 | { 35 | temp = convert_temp(data->temp_inside_out); 36 | if(is_temp_valid(temp)) 37 | this->temp_inside_out_->publish_state(temp); 38 | } 39 | 40 | if(this->temp_inside_in_ != nullptr) 41 | { 42 | temp = convert_temp(data->temp_inside_in); 43 | if(is_temp_valid(temp)) 44 | this->temp_inside_in_->publish_state(temp); 45 | } 46 | 47 | if(this->temp_outside_out_ != nullptr) 48 | { 49 | temp = convert_temp(data->temp_outside_out); 50 | if(is_temp_valid(temp)) 51 | this->temp_outside_out_->publish_state(temp); 52 | } 53 | 54 | if(this->temp_outside_in_ != nullptr) 55 | { 56 | temp = convert_temp(data->temp_outside_in); 57 | if(is_temp_valid(temp)) 58 | this->temp_outside_in_->publish_state(temp); 59 | } 60 | 61 | if(this->humidity_ != nullptr) 62 | { 63 | auto humidity = data->humidity - 128; 64 | if(humidity < 100 && humidity > 0) 65 | this->humidity_->publish_state(humidity); 66 | } 67 | 68 | //TODO: add validation 69 | if(this->pressure_ != nullptr) 70 | this->pressure_->publish_state(512 + data->pressure); 71 | 72 | if(this->tvoc_ != nullptr) 73 | this->tvoc_->publish_state(byteswap(data->voc) & 0x7fff); 74 | 75 | if(this->co2_ != nullptr) 76 | this->co2_->publish_state(byteswap(data->co2) & 0x3fff); 77 | 78 | if(this->voltage_ != nullptr && data->voltage > 0) 79 | this->voltage_->publish_state(data->voltage); 80 | 81 | if(this->frequency_ != nullptr && data->frequency > 0) 82 | this->frequency_->publish_state(data->frequency); 83 | 84 | if(this->timestamp_ != nullptr && data->timestamp > 0) 85 | this->timestamp_->publish_state(data->timestamp); 86 | } 87 | 88 | 89 | } // namespace prana_ble 90 | } // namespace esphome 91 | 92 | #endif 93 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/fan/prana_ble_fan.cpp: -------------------------------------------------------------------------------- 1 | #include "prana_ble_fan.h" 2 | #include "esphome/core/log.h" 3 | 4 | #ifdef USE_ESP32 5 | 6 | namespace esphome { 7 | namespace prana_ble { 8 | 9 | using namespace esphome::fan; 10 | 11 | 12 | void PranaBLEFan::setup() 13 | { 14 | this->traits_ = fan::FanTraits(false, true, false, speed_count_); 15 | } 16 | void PranaBLEFan::dump_config() { LOG_FAN("", "Prana BLE Fan", this); } 17 | std::string PranaBLEFan::describe() { return "Prana BLE Fan"; } 18 | 19 | void PranaBLEFan::control(const fan::FanCall &call) { 20 | if (!this->parent_->is_connected()) { 21 | ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); 22 | return; 23 | } 24 | auto fans_locked = parent_->get_fans_locked(); 25 | if( (fans_locked && fan_type_ != PranaFan::FAN_BOTH) 26 | || (!fans_locked && fan_type_ == PranaFan::FAN_BOTH) ) 27 | { 28 | ESP_LOGW(TAG, "Cannot control this fan. Check fan lock switch"); 29 | return; 30 | } 31 | bool did_change = false; 32 | if(call.get_state().has_value() && call.get_state() != this->state) 33 | { 34 | if(this->state) 35 | this->parent_->set_fan_off(fan_type_); 36 | else 37 | this->parent_->set_fan_on(fan_type_); 38 | this->state = !this->state; 39 | did_change = true; 40 | } 41 | // ignore speed changes if not on or turning on 42 | if (this->state && call.get_speed().has_value()) { 43 | auto speed = *call.get_speed(); 44 | if (speed > 0) { 45 | this->speed = speed; 46 | ESP_LOGW(TAG, "Setting fan speed %d", speed); 47 | this->parent_->set_fan_speed(fan_type_, speed, direct_); 48 | did_change = true; 49 | } 50 | } 51 | 52 | if (did_change) { 53 | this->publish_state(); 54 | } 55 | } 56 | 57 | void PranaBLEFan::on_status(const PranaStatusPacket *data) { 58 | uint8_t data_speed = this->speed; 59 | bool enabled = this->state; 60 | switch(fan_type_) 61 | { 62 | case PranaFan::FAN_IN: 63 | { 64 | data_speed = data->speed_in / 10; 65 | enabled = data->input_enabled; 66 | break; 67 | } 68 | case PranaFan::FAN_OUT: 69 | { 70 | data_speed = data->speed_out / 10; 71 | enabled = data->output_enabled; 72 | break; 73 | } 74 | case PranaFan::FAN_BOTH: 75 | { 76 | data_speed = data->speed / 10; 77 | enabled = data->enabled; 78 | break; 79 | } 80 | } 81 | 82 | if (data_speed != this->speed) { 83 | this->speed = data_speed; 84 | this->publish_state(); 85 | } 86 | if (enabled != this->state) { 87 | this->state = enabled; 88 | this->publish_state(); 89 | } 90 | 91 | } 92 | } // namespace prana_ble 93 | } // namespace esphome 94 | 95 | #endif 96 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/fan/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | from esphome.components import fan 6 | from esphome.const import ( 7 | CONF_ID, 8 | CONF_NAME, 9 | CONF_SPEED_COUNT, 10 | CONF_ENTITY_CATEGORY, 11 | CONF_ICON 12 | ) 13 | from .. import ( 14 | PRANA_BLE_CLIENT_SCHEMA, 15 | CONF_PRANA_ID, 16 | prana_ble_ns, 17 | PranaBLEHub, 18 | register_prana_child, 19 | ) 20 | 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | CODEOWNERS = ["@voed"] 24 | DEPENDENCIES = ["prana_ble"] 25 | 26 | PranaBLEFan = prana_ble_ns.class_("PranaBLEFan", fan.Fan, cg.Component) 27 | PranaFan = prana_ble_ns.enum("PranaFan") 28 | 29 | PRANA_FAN_TYPE = { 30 | "INLET": PranaFan.FAN_IN, 31 | "OUTLET": PranaFan.FAN_OUT, 32 | "BOTH": PranaFan.FAN_BOTH 33 | } 34 | 35 | CONF_HAS_AUTO = "has_auto" 36 | CONF_FAN_TYPE = "fan_type" 37 | 38 | CONF_FAN_IN = "fan_in" 39 | CONF_FAN_OUT = "fan_out" 40 | CONF_FAN_BOTH = "fan_both" 41 | CONF_DIRECT = "direct_control" 42 | ICON_FAN_CHEVRON_UP = "mdi:fan-chevron-up" 43 | ICON_FAN_CHEVRON_DOWN = "mdi:fan-chevron-down" 44 | 45 | CONFIG_SCHEMA = ( 46 | cv.Schema( 47 | { 48 | cv.Optional(CONF_FAN_IN): fan.fan_schema( 49 | PranaBLEFan, 50 | icon=ICON_FAN_CHEVRON_DOWN 51 | ), 52 | cv.Optional(CONF_FAN_OUT): fan.fan_schema( 53 | PranaBLEFan, 54 | icon=ICON_FAN_CHEVRON_UP 55 | ), 56 | cv.Optional(CONF_FAN_BOTH): fan.fan_schema(PranaBLEFan), 57 | cv.Optional(CONF_SPEED_COUNT, default=10): cv.int_range(1, 10), 58 | cv.Optional(CONF_DIRECT, default=False): cv.boolean 59 | } 60 | ) 61 | .extend(cv.COMPONENT_SCHEMA) 62 | .extend(PRANA_BLE_CLIENT_SCHEMA) 63 | ) 64 | 65 | 66 | 67 | async def to_code(config): 68 | if CONF_FAN_IN in config: 69 | var = await fan.new_fan(config[CONF_FAN_IN]); 70 | await cg.register_component(var, config) 71 | cg.add(var.set_fan_type(PranaFan.FAN_IN)) 72 | cg.add(var.set_speed_count(config[CONF_SPEED_COUNT])) 73 | cg.add(var.set_fan_direct(config[CONF_DIRECT])) 74 | await register_prana_child(var, config) 75 | 76 | if CONF_FAN_OUT in config: 77 | var = await fan.new_fan(config[CONF_FAN_OUT]); 78 | await cg.register_component(var, config) 79 | cg.add(var.set_fan_type(PranaFan.FAN_OUT)) 80 | cg.add(var.set_speed_count(config[CONF_SPEED_COUNT])) 81 | cg.add(var.set_fan_direct(config[CONF_DIRECT])) 82 | await register_prana_child(var, config) 83 | 84 | if CONF_FAN_BOTH in config: 85 | var = await fan.new_fan(config[CONF_FAN_BOTH]); 86 | await cg.register_component(var, config) 87 | cg.add(var.set_fan_type(PranaFan.FAN_BOTH)) 88 | cg.add(var.set_speed_count(config[CONF_SPEED_COUNT])) 89 | cg.add(var.set_fan_direct(config[CONF_DIRECT])) 90 | await register_prana_child(var, config) 91 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/switch/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | from esphome.components import switch 6 | from esphome.const import ( 7 | CONF_ID, 8 | ICON_POWER 9 | ) 10 | from .. import ( 11 | PRANA_BLE_CLIENT_SCHEMA, 12 | prana_ble_ns, 13 | register_prana_child, 14 | ) 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | CODEOWNERS = ["@voed"] 18 | DEPENDENCIES = ["prana_ble"] 19 | 20 | PranaBLESwitch = prana_ble_ns.class_("PranaBLESwitch", switch.Switch, cg.Component) 21 | PranaSwitchType = prana_ble_ns.enum("PranaSwitchType") 22 | 23 | CONF_ENABLE = "enable" 24 | CONF_HEATING = "heating" 25 | CONF_WINTER_MODE = "winter_mode" 26 | CONF_AUTO_MODE = "auto_mode" 27 | CONF_CONNECT = "connect" 28 | CONF_FAN_LOCK = "fan_lock" 29 | 30 | ICON_BT_CONNECT="mdi:bluetooth-connect" 31 | ICON_HEAT="mdi:heat-wave" 32 | ICON_WINTER="mdi:snowflake-thermometer" 33 | ICON_LOCK="mdi:lock" 34 | CONFIG_SCHEMA = ( 35 | cv.Schema( 36 | { 37 | #cv.GenerateID(): cv.declare_id(PranaBLESwitch), 38 | cv.Required(CONF_CONNECT): switch.switch_schema( 39 | PranaBLESwitch, 40 | icon=ICON_BT_CONNECT 41 | ), 42 | cv.Optional(CONF_ENABLE): switch.switch_schema( 43 | PranaBLESwitch, 44 | icon=ICON_POWER 45 | ), 46 | cv.Optional(CONF_HEATING): switch.switch_schema( 47 | PranaBLESwitch, 48 | icon=ICON_HEAT 49 | ), 50 | cv.Optional(CONF_WINTER_MODE): switch.switch_schema( 51 | PranaBLESwitch, 52 | icon=ICON_WINTER 53 | ), 54 | cv.Optional(CONF_FAN_LOCK): switch.switch_schema( 55 | PranaBLESwitch, 56 | icon=ICON_LOCK 57 | ) 58 | } 59 | ) 60 | .extend(cv.COMPONENT_SCHEMA) 61 | .extend(PRANA_BLE_CLIENT_SCHEMA) 62 | ) 63 | 64 | async def to_code(config): 65 | 66 | if CONF_CONNECT in config: 67 | sw = await switch.new_switch(config[CONF_CONNECT]) 68 | await cg.register_component(sw, config) 69 | cg.add(sw.set_switch_type(PranaSwitchType.CONNECT)) 70 | await register_prana_child(sw, config) 71 | if CONF_ENABLE in config: 72 | sw = await switch.new_switch(config[CONF_ENABLE]) 73 | await cg.register_component(sw, config) 74 | cg.add(sw.set_switch_type(PranaSwitchType.ENABLE)) 75 | await register_prana_child(sw, config) 76 | 77 | if CONF_HEATING in config: 78 | sw = await switch.new_switch(config[CONF_HEATING]) 79 | await cg.register_component(sw, config) 80 | cg.add(sw.set_switch_type(PranaSwitchType.HEAT)) 81 | await register_prana_child(sw, config) 82 | 83 | if CONF_WINTER_MODE in config: 84 | sw = await switch.new_switch(config[CONF_WINTER_MODE]) 85 | await cg.register_component(sw, config) 86 | cg.add(sw.set_switch_type(PranaSwitchType.WINTER)) 87 | await register_prana_child(sw, config) 88 | 89 | 90 | if CONF_FAN_LOCK in config: 91 | sw = await switch.new_switch(config[CONF_FAN_LOCK]) 92 | await cg.register_component(sw, config) 93 | cg.add(sw.set_switch_type(PranaSwitchType.FAN_LOCK)) 94 | await register_prana_child(sw, config) -------------------------------------------------------------------------------- /prana_conf_example.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | name: prana 3 | device_description: "Monitor and control a Parana Recuperator via bluetooth" 4 | external_components_source: github://voed/prana_ble@master 5 | mac_address_01: 00:A0:50:00:00:00 6 | mac_address_02: 00:A0:50:00:00:01 7 | 8 | esphome: 9 | friendly_name: Prana 10 | name: ${name} 11 | comment: ${device_description} 12 | 13 | esp32: 14 | board: esp32dev 15 | framework: # Strongly recommended to use IDF framework instead of arduino. 16 | type: esp-idf 17 | #version: 5.1.2 #Optionally, you can even specify a newer IDF version. It may contain some fixes and work better in some cases. 18 | #platform_version: 6.5.0 19 | #sdkconfig_options: 20 | # CONFIG_ESPTOOLPY_FLASHSIZE_4MB: y 21 | 22 | external_components: 23 | - source: ${external_components_source} 24 | components: [prana_ble] 25 | refresh: 0s 26 | 27 | # Enable logging 28 | logger: 29 | level: DEBUG 30 | 31 | # Enable Home Assistant API 32 | api: 33 | encryption: 34 | key: 35 | 36 | ota: 37 | platform: esphome 38 | password: 39 | 40 | wifi: 41 | ssid: !secret wifi_ssid 42 | password: !secret wifi_password 43 | 44 | # Enable fallback hotspot (captive portal) in case wifi connection fails 45 | ap: 46 | ssid: "Prana Fallback Hotspot" 47 | password: !secret wifi_password 48 | 49 | # MAC address can be obtained via "BLE Scanner" app on your smartphone or BLE Client Tracker component 50 | 51 | # Enables scanning of Bluetooth (BLE) devices via ESP32. To determine the MAC address 52 | # esp32_ble_tracker: 53 | # text_sensor: 54 | # - platform: ble_scanner 55 | # name: "BLE Devices Scanner" 56 | 57 | # TODO: make option to add by device name? 58 | ble_client: 59 | - mac_address: ${mac_address_01} 60 | id: prana 61 | #You can use multiple devices by specifying their MACS and ids 62 | #- mac_address: ${mac_address_02} #Uncomment if you want to connect multiple recuperators to this ESP 63 | # id: prana2 64 | 65 | prana_ble: 66 | - id: prana_client 67 | ble_client_id: prana 68 | #- id: prana_client2 #Uncomment if you want to connect multiple recuperators to this ESP 69 | # ble_client_id: prana2 70 | 71 | sensor: 72 | - platform: prana_ble 73 | prana_ble_id: prana_client 74 | 75 | #temperature sensors 76 | temperature_inside_inlet: # top left sensor in Prana app 77 | name: "Temperature inside inlet" 78 | temperature_inside_outlet: # bottom left sensor in Prana app 79 | name: "Temperature inside outlet" 80 | temperature_outside_inlet: # top right sensor in Prana app 81 | name: "Temperature outside inlet" 82 | temperature_outside_outlet: # bottom right sensor in Prana app 83 | name: "Temperature outside outlet" 84 | 85 | co2: 86 | name: "CO2" 87 | pressure: 88 | name: "Pressure" 89 | humidity: 90 | name: "Humidity" 91 | tvoc: 92 | name: "TVOC" 93 | 94 | # voltage and frequency sensors seems only available for old devices with 220V fans 95 | voltage: 96 | name: "Voltage" 97 | frequency: 98 | name: "Frequency" 99 | timestamp: # Current date and time from your recuperator. Not sure how it can be useful, but it's here. Not available on some old devices. 100 | name: "Timestamp" 101 | 102 | switch: 103 | - platform: prana_ble 104 | prana_ble_id: prana_client 105 | connect: # Switch to turn off BLE connection between recuperator and ESP32. Useful if you want to connect from native smartphone app 106 | name: Bluetooth connect 107 | restore_mode: ALWAYS_ON 108 | enable: # turn on/off the device 109 | name: Enable 110 | heating: # turn on/off internal heating element 111 | name: Heating 112 | winter_mode: # turn on/off "winter" mode 113 | name: Winter mode 114 | fan_lock: # turn off to control in/out fans separately 115 | name: Fan lock 116 | 117 | fan: 118 | - platform: prana_ble 119 | prana_ble_id: prana_client 120 | fan_in: 121 | name: Inlet fan 122 | fan_out: 123 | name: Outlet fan 124 | fan_both: # use fan_lock switch to control separate fans 125 | name: In/Out fans 126 | speed_count: 10 # how many speeds your recuperator have. Default is 10 127 | direct_control: false # Direct fan control. More reliable and efficient. False by default because not supported by some old devices 128 | 129 | number: 130 | - platform: prana_ble 131 | prana_ble_id: prana_client 132 | name: Display brightness 133 | 134 | select: 135 | - platform: prana_ble 136 | prana_ble_id: prana_client 137 | 138 | # select what to display on your recuperator screen: "Fan", "Temp inside", "Temp outside", "CO2", "VOC", "Humidity", "Air quality", "Pressure", "Date", "Time" 139 | display_mode: 140 | name: "Display mode" 141 | 142 | # switch between Auto, Auto+ and Manual modes 143 | fan_mode: 144 | name: "Fan mode" 145 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/prana_ble_const.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace esphome { 6 | namespace prana_ble { 7 | 8 | static const char *const TAG = "prana_ble"; 9 | 10 | static const uint16_t PRANA_MAGIC = 0xEFBE; 11 | static const uint8_t PRANA_CMD_PREFIX = 0x04; 12 | static const uint8_t PRANA_STATE[] = {0xBE, 0xEF, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x5A}; 13 | 14 | //Only new devices - direct fan speed control 15 | //inlet fan 16 | static const uint8_t cmd_fan_in_speed[10] = { 17 | 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28 18 | }; 19 | 20 | //outlet fan 21 | static const uint8_t cmd_fan_out_speed[10] = { 22 | 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32 23 | }; 24 | 25 | //both fans 26 | static const uint8_t cmd_fan_speed[10] = { 27 | 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C 28 | }; 29 | 30 | struct PranaCmdPacket 31 | { 32 | uint16_t magic; 33 | uint8_t prefix; 34 | uint8_t command; 35 | 36 | PranaCmdPacket(uint8_t command) 37 | { 38 | magic = PRANA_MAGIC; 39 | prefix = PRANA_CMD_PREFIX; 40 | this->command = command; 41 | } 42 | } __attribute__((packed)); 43 | 44 | enum PranaFan : uint8_t { 45 | FAN_IN = 0x01, 46 | FAN_OUT = 0x02, 47 | FAN_BOTH = 0x05 48 | }; 49 | 50 | enum PranaFanMode : uint8_t { 51 | Manual, 52 | Auto, 53 | Auto_Plus 54 | }; 55 | 56 | enum PranaDisplayMode : uint8_t { 57 | FAN = 0, 58 | TEMP_IN = 1, 59 | TEMP_OUT = 2, 60 | CO2 = 3, 61 | VOC = 4, 62 | HUMIDITY = 5, 63 | AIR_QUALITY = 6, 64 | PRESSURE = 7, 65 | UNUSED = 8, 66 | DATE = 9, 67 | TIME = 10, 68 | MODE_COUNT = 11 69 | }; 70 | 71 | enum PranaCommand : uint8_t { 72 | CMD_POWER_OFF = 0x01, 73 | CMD_BRIGHTNESS = 0x02, 74 | CMD_HEATING = 0x05, 75 | CMD_NIGHT_MODE = 0x06, 76 | CMD_HIGH_SPEED = 0x07, 77 | CMD_FAN_LOCK = 0x09, 78 | CMD_FAN_OUT_OFF = 0x10, 79 | CMD_FAN_OUT_SPEED_UP = 0x11, 80 | CMD_FAN_OUT_SPEED_DOWN = 0x12, 81 | CMD_WINTER_MODE = 0x16, 82 | CMD_AUTO_MODE = 0x18, 83 | CMD_POWER_ON = 0x0A, 84 | CMD_FAN_SPEED_DOWN = 0x0B, 85 | CMD_FAN_SPEED_UP = 0x0C, 86 | CMD_FAN_IN_SPEED_UP = 0x0E, 87 | CMD_FAN_IN_SPEED_DOWN = 0x0F, 88 | CMD_FAN_IN_OFF = 0x0D, 89 | CMD_DISPLAY_LEFT = 0x19, 90 | CMD_DISPLAY_RIGHT = 0x1A, 91 | 92 | 93 | 94 | //inlet fan 95 | CMD_FAN_IN_SPEED1 = 0x1F, 96 | CMD_FAN_IN_SPEED2 = 0x20, 97 | CMD_FAN_IN_SPEED3 = 0x21, 98 | CMD_FAN_IN_SPEED4 = 0x22, 99 | CMD_FAN_IN_SPEED5 = 0x23, 100 | CMD_FAN_IN_SPEED6 = 0x24, 101 | CMD_FAN_IN_SPEED7 = 0x25, 102 | CMD_FAN_IN_SPEED8 = 0x26, 103 | CMD_FAN_IN_SPEED9 = 0x27, 104 | CMD_FAN_IN_SPEED10 = 0x28, 105 | 106 | //outlet fan 107 | CMD_FAN_OUT_SPEED1 = 0x29, 108 | CMD_FAN_OUT_SPEED2 = 0x2A, 109 | CMD_FAN_OUT_SPEED3 = 0x2B, 110 | CMD_FAN_OUT_SPEED4 = 0x2C, 111 | CMD_FAN_OUT_SPEED5 = 0x2D, 112 | CMD_FAN_OUT_SPEED6 = 0x2E, 113 | CMD_FAN_OUT_SPEED7 = 0x2F, 114 | CMD_FAN_OUT_SPEED8 = 0x30, 115 | CMD_FAN_OUT_SPEED9 = 0x31, 116 | CMD_FAN_OUT_SPEED10 = 0x32, 117 | 118 | //both fans 119 | CMD_FAN_SPEED1 = 0x33, 120 | CMD_FAN_SPEED2 = 0x34, 121 | CMD_FAN_SPEED3 = 0x35, 122 | CMD_FAN_SPEED4 = 0x36, 123 | CMD_FAN_SPEED5 = 0x37, 124 | CMD_FAN_SPEED6 = 0x38, 125 | CMD_FAN_SPEED7 = 0x39, 126 | CMD_FAN_SPEED8 = 0x3A, 127 | CMD_FAN_SPEED9 = 0x3B, 128 | CMD_FAN_SPEED10 = 0x3C 129 | }; 130 | 131 | 132 | struct PranaStatusPacket { 133 | uint16_t magic; 134 | uint8_t prefix[2]; 135 | uint32_t timestamp; 136 | uint8_t suffix; 137 | uint8_t unused1; // C0 138 | bool enabled : 8;//[10] 139 | uint8_t unused2; 140 | uint8_t brightness : 8;//12 141 | uint8_t unused3; 142 | bool heating_on : 8; //14 143 | uint8_t unused4; 144 | bool night_mode : 8; //16 145 | uint8_t unused11; 146 | uint8_t unknown1[2]; 147 | PranaFanMode fan_mode; //20 148 | uint8_t unused5; 149 | bool fans_locked : 8; //22 150 | uint8_t unknown2[3]; 151 | uint8_t speed;//26 152 | uint8_t unused6; 153 | bool input_enabled : 8;//28 154 | uint8_t unknown3; 155 | uint8_t speed_in;//30 156 | uint8_t unused7; 157 | bool output_enabled : 8;//32 158 | uint8_t unused8; 159 | uint8_t speed_out;//34 160 | uint8_t unknown4[6];//35-40 161 | uint8_t unused9; 162 | bool winter_mode : 8;//42 163 | uint8_t unknown5[5];//47 164 | short temp_inside_in;//48-49 165 | uint8_t unknown6; 166 | short temp_outside_in;//51-52 //short? 167 | uint8_t unknown7; 168 | 169 | short temp_inside_out; 170 | 171 | uint8_t unknown8;//56 172 | short temp_outside_out; //57-58 173 | uint8_t unknown9;//59 174 | uint8_t humidity;//60 175 | short co2;//61-62 //short? 176 | short voc;//63-64 177 | uint8_t unknown10[13];//65-77 178 | uint8_t pressure;//78 179 | uint8_t unknown11[15];//79-93 180 | uint8_t unused10;//C0 181 | uint8_t unknown12;//95 182 | uint8_t voltage;//96 183 | uint8_t frequency;//97 184 | uint8_t unknown13;//98 185 | uint8_t display_mode;//99 186 | } __attribute__((packed)); 187 | 188 | 189 | static const std::vector PRANA_FAN_MODES {"Manual", "Auto", "Auto+"}; 190 | static const std::vector PRANA_DISPLAY_MODES {"Fan", "Temp inside", "Temp outside", "CO2", "VOC", "Humidity", "Air quality", "Pressure", "unused", "Date", "Time"}; 191 | 192 | } // namespace prana_ble 193 | } // namespace esphome 194 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/prana_ble_hub.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/components/ble_client/ble_client.h" 4 | #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" 5 | #include "esphome/core/component.h" 6 | #include "esphome/core/defines.h" 7 | #include "esphome/core/hal.h" 8 | #include "esphome/core/log.h" 9 | #include "prana_ble_child.h" 10 | #include "prana_ble_const.h" 11 | #include 12 | 13 | #ifdef USE_ESP32 14 | 15 | #include 16 | 17 | namespace esphome { 18 | namespace prana_ble { 19 | 20 | 21 | 22 | namespace espbt = esphome::esp32_ble_tracker; 23 | 24 | // Forward declare PranaBLEClient 25 | class PranaBLEClient; 26 | 27 | static const espbt::ESPBTUUID PRANA_SERVICE_UUID = espbt::ESPBTUUID::from_uint16(0xBABA); 28 | static const espbt::ESPBTUUID PRANA_CHAR_UUID = espbt::ESPBTUUID::from_uint16(0xCCCC); 29 | 30 | /** 31 | * Hub component connecting to the BedJet device over Bluetooth. 32 | */ 33 | class PranaBLEHub : public esphome::ble_client::BLEClientNode, public PollingComponent { 34 | public: 35 | /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ 36 | bool command_connect(); 37 | bool command_disconnect(); 38 | 39 | bool command_poweroff() { return send_command(CMD_POWER_OFF, true); } 40 | bool command_poweron() { return send_command(CMD_POWER_ON, true); } 41 | bool command_brightness() { return send_command(CMD_BRIGHTNESS, true); } 42 | bool command_heating() { return send_command(CMD_HEATING, true); } 43 | bool command_night_mode() { return send_command(CMD_NIGHT_MODE, true); } 44 | bool command_high_speed() { return send_command(CMD_HIGH_SPEED, true); } 45 | bool command_fans_lock() { return send_command(CMD_FAN_LOCK, true); } 46 | bool command_fan_out_off() { return send_command(CMD_FAN_OUT_OFF, true); } 47 | bool command_fan_out_speed_up() { return send_command(CMD_FAN_OUT_SPEED_UP, true); } 48 | bool command_fan_out_speed_down() { return send_command(CMD_FAN_OUT_SPEED_DOWN, true); } 49 | bool command_winter_mode() { return send_command(CMD_WINTER_MODE, true); } 50 | bool command_auto_mode() { return send_command(CMD_AUTO_MODE, true); } 51 | bool command_fans_speed_down() { return send_command(CMD_FAN_SPEED_DOWN, true); } 52 | bool command_fans_speed_up() { return send_command(CMD_FAN_SPEED_UP, true); } 53 | bool command_fan_in_speed_up() { return send_command(CMD_FAN_IN_SPEED_UP, true); } 54 | bool command_fan_in_speed_down() { return send_command(CMD_FAN_IN_SPEED_DOWN, true); } 55 | bool command_fan_in_off() { return send_command(CMD_FAN_IN_OFF, true); } 56 | 57 | void set_fans_locked(bool locked); 58 | bool get_fans_locked(); 59 | 60 | short get_fan_speed(PranaFan fan); 61 | bool set_fan_speed(PranaFan fan, short new_speed, bool direct=false); 62 | bool set_fan_speed_direct(PranaFan fan, short new_speed); 63 | 64 | bool set_fan_off(PranaFan fan); 65 | bool set_fan_step(PranaFan fan, bool up); 66 | bool set_fan_on(PranaFan fan); 67 | 68 | bool set_auto_mode(PranaFanMode new_mode); 69 | 70 | short get_brightness(); 71 | void set_brightness(short value); 72 | 73 | short get_display_mode(); 74 | void set_display_mode(short mode); 75 | 76 | /** Send the `button`. */ 77 | bool send_command(const PranaCommand command, bool update=false); 78 | bool send_command(const uint8_t command, bool update=false); 79 | 80 | /** @return `true` if the `BLEClient::node_state` is `ClientState::ESTABLISHED`. */ 81 | bool is_connected() { return this->node_state == espbt::ClientState::ESTABLISHED; } 82 | bool is_connection_enabled() { return this->keep_connected_; } 83 | 84 | bool has_status() { return true;} //this->status != nullptr; } 85 | 86 | /** Register a `BedJetClient` child component. */ 87 | void register_child(PranaBLEClient *obj); 88 | 89 | /** Set the status timeout. 90 | * 91 | * This is the max time to wait for a status update before the connection is presumed unusable. 92 | */ 93 | void set_status_timeout(uint32_t timeout) { this->timeout_ = timeout; } 94 | 95 | /* Component overrides */ 96 | void update() override; 97 | void dump_config() override; 98 | float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } 99 | 100 | 101 | PranaStatusPacket* get_status_packet() { send_update_request(); return &status; } 102 | 103 | /** @return The Prana's configured name, or the MAC address if not discovered yet. */ 104 | std::string get_name() { 105 | if (this->name_.empty()) { 106 | return this->parent_->address_str(); 107 | } else { 108 | return this->name_; 109 | } 110 | } 111 | 112 | /* BLEClient overrides */ 113 | void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, 114 | esp_ble_gattc_cb_param_t *param) override; 115 | 116 | protected: 117 | std::vector children_; 118 | void dispatch_status_(); 119 | void dispatch_state_(bool is_ready); 120 | 121 | uint32_t timeout_{DEFAULT_STATUS_TIMEOUT}; 122 | static const uint32_t NOTIFY_WARN_THRESHOLD = 300000; 123 | static const uint32_t DEFAULT_STATUS_TIMEOUT = 900000; 124 | 125 | PranaStatusPacket status; 126 | bool fans_locked_; 127 | 128 | uint8_t set_notify_(bool enable); 129 | /** Send the `PranaCmdPacket` to the device. */ 130 | uint8_t send_packet(PranaCmdPacket *pkt, bool update=false); 131 | uint8_t send_data(uint8_t data[], uint8_t len); 132 | 133 | void set_name_(const std::string &name) { this->name_ = name; } 134 | uint8_t send_update_request(); 135 | std::string name_; 136 | 137 | uint32_t last_notify_ = 0; 138 | bool force_refresh_ = false; 139 | bool keep_connected_ = true; 140 | 141 | bool discover_characteristics_(); 142 | uint16_t char_handle_; 143 | uint16_t config_descr_status_; 144 | 145 | uint8_t write_notify_config_descriptor_(bool enable); 146 | }; 147 | 148 | } // namespace prana_ble 149 | } // namespace esphome 150 | 151 | #endif 152 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/sensor/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import esphome.codegen as cg 3 | import esphome.config_validation as cv 4 | from esphome.components import sensor 5 | from esphome.const import ( 6 | CONF_CO2, 7 | CONF_FREQUENCY, 8 | CONF_HUMIDITY, 9 | CONF_ID, 10 | CONF_PRESSURE, 11 | CONF_TEMPERATURE, 12 | CONF_TVOC, 13 | CONF_VOLTAGE, 14 | DEVICE_CLASS_CARBON_DIOXIDE, 15 | DEVICE_CLASS_FREQUENCY, 16 | DEVICE_CLASS_HUMIDITY, 17 | DEVICE_CLASS_TEMPERATURE, 18 | DEVICE_CLASS_TIMESTAMP, 19 | DEVICE_CLASS_ATMOSPHERIC_PRESSURE, 20 | DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, 21 | DEVICE_CLASS_VOLTAGE, 22 | ENTITY_CATEGORY_DIAGNOSTIC, 23 | STATE_CLASS_MEASUREMENT, 24 | UNIT_CELSIUS, 25 | UNIT_HERTZ, 26 | UNIT_PARTS_PER_BILLION, 27 | UNIT_PARTS_PER_MILLION, 28 | UNIT_PERCENT, 29 | UNIT_VOLT, 30 | UNIT_HECTOPASCAL, 31 | ICON_TIMER 32 | ) 33 | 34 | from .. import ( 35 | PRANA_BLE_CLIENT_SCHEMA, 36 | prana_ble_ns, 37 | register_prana_child, 38 | ) 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | CODEOWNERS = ["@voed"] 42 | DEPENDENCIES = ["prana_ble"] 43 | CONF_INSIDE_OUTLET_TEMP = "temperature_inside_outlet" 44 | CONF_INSIDE_INLET_TEMP = "temperature_inside_inlet" 45 | CONF_OUTSIDE_OUTLET_TEMP = "temperature_outside_outlet" 46 | CONF_OUTSIDE_INLET_TEMP = "temperature_outside_inlet" 47 | CONF_TIMESTAMP = "timestamp" 48 | UNIT_MMHG = "mmHg" 49 | 50 | ICON_THERMOMETER_CHEVRON_UP = "mdi:thermometer-chevron-up" 51 | ICON_THERMOMETER_CHEVRON_DOWN = "mdi:thermometer-chevron-down" 52 | 53 | PranaBLESensor = prana_ble_ns.class_("PranaBLESensors", cg.Component) 54 | 55 | CONFIG_SCHEMA = ( 56 | cv.Schema( 57 | { 58 | cv.GenerateID(): cv.declare_id(PranaBLESensor), 59 | cv.Optional(CONF_INSIDE_OUTLET_TEMP): sensor.sensor_schema( 60 | unit_of_measurement=UNIT_CELSIUS, 61 | accuracy_decimals=1, 62 | device_class=DEVICE_CLASS_TEMPERATURE, 63 | state_class=STATE_CLASS_MEASUREMENT, 64 | icon=ICON_THERMOMETER_CHEVRON_UP 65 | ), 66 | cv.Optional(CONF_INSIDE_INLET_TEMP): sensor.sensor_schema( 67 | unit_of_measurement=UNIT_CELSIUS, 68 | accuracy_decimals=1, 69 | device_class=DEVICE_CLASS_TEMPERATURE, 70 | state_class=STATE_CLASS_MEASUREMENT, 71 | icon=ICON_THERMOMETER_CHEVRON_DOWN 72 | ), 73 | cv.Optional(CONF_OUTSIDE_OUTLET_TEMP): sensor.sensor_schema( 74 | unit_of_measurement=UNIT_CELSIUS, 75 | accuracy_decimals=1, 76 | device_class=DEVICE_CLASS_TEMPERATURE, 77 | state_class=STATE_CLASS_MEASUREMENT, 78 | icon=ICON_THERMOMETER_CHEVRON_UP 79 | ), 80 | cv.Optional(CONF_OUTSIDE_INLET_TEMP): sensor.sensor_schema( 81 | unit_of_measurement=UNIT_CELSIUS, 82 | accuracy_decimals=1, 83 | device_class=DEVICE_CLASS_TEMPERATURE, 84 | state_class=STATE_CLASS_MEASUREMENT, 85 | icon=ICON_THERMOMETER_CHEVRON_DOWN 86 | ), 87 | cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( 88 | unit_of_measurement=UNIT_PERCENT, 89 | accuracy_decimals=0, 90 | device_class=DEVICE_CLASS_HUMIDITY, 91 | state_class=STATE_CLASS_MEASUREMENT, 92 | ), 93 | cv.Optional(CONF_PRESSURE): sensor.sensor_schema( 94 | unit_of_measurement=UNIT_MMHG, 95 | accuracy_decimals=0, 96 | device_class=DEVICE_CLASS_ATMOSPHERIC_PRESSURE, 97 | state_class=STATE_CLASS_MEASUREMENT, 98 | ), 99 | cv.Optional(CONF_CO2): sensor.sensor_schema( 100 | unit_of_measurement=UNIT_PARTS_PER_MILLION, 101 | accuracy_decimals=0, 102 | device_class=DEVICE_CLASS_CARBON_DIOXIDE, 103 | state_class=STATE_CLASS_MEASUREMENT, 104 | ), 105 | cv.Optional(CONF_TVOC): sensor.sensor_schema( 106 | unit_of_measurement=UNIT_PARTS_PER_BILLION, 107 | accuracy_decimals=0, 108 | device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, 109 | state_class=STATE_CLASS_MEASUREMENT, 110 | ), 111 | cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( 112 | unit_of_measurement=UNIT_VOLT, 113 | accuracy_decimals=0, 114 | device_class=DEVICE_CLASS_VOLTAGE, 115 | state_class=STATE_CLASS_MEASUREMENT, 116 | entity_category=ENTITY_CATEGORY_DIAGNOSTIC, 117 | ), 118 | cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( 119 | unit_of_measurement=UNIT_HERTZ, 120 | accuracy_decimals=0, 121 | device_class=DEVICE_CLASS_FREQUENCY, 122 | state_class=STATE_CLASS_MEASUREMENT, 123 | entity_category=ENTITY_CATEGORY_DIAGNOSTIC, 124 | ), 125 | cv.Optional(CONF_TIMESTAMP): sensor.sensor_schema( 126 | accuracy_decimals=0, 127 | device_class=DEVICE_CLASS_TIMESTAMP, 128 | entity_category=ENTITY_CATEGORY_DIAGNOSTIC, 129 | ) 130 | } 131 | ) 132 | .extend(cv.COMPONENT_SCHEMA) 133 | .extend(PRANA_BLE_CLIENT_SCHEMA) 134 | ) 135 | 136 | 137 | async def to_code(config): 138 | var = cg.new_Pvariable(config[CONF_ID]) 139 | await cg.register_component(var, config) 140 | await register_prana_child(var, config) 141 | 142 | if CONF_INSIDE_OUTLET_TEMP in config: 143 | sens = await sensor.new_sensor(config[CONF_INSIDE_OUTLET_TEMP]) 144 | cg.add(var.set_temp_inside_out(sens)) 145 | 146 | if CONF_INSIDE_INLET_TEMP in config: 147 | sens = await sensor.new_sensor(config[CONF_INSIDE_INLET_TEMP]) 148 | cg.add(var.set_temp_inside_in(sens)) 149 | 150 | if CONF_OUTSIDE_OUTLET_TEMP in config: 151 | sens = await sensor.new_sensor(config[CONF_OUTSIDE_OUTLET_TEMP]) 152 | cg.add(var.set_temp_outside_out(sens)) 153 | 154 | if CONF_OUTSIDE_INLET_TEMP in config: 155 | sens = await sensor.new_sensor(config[CONF_OUTSIDE_INLET_TEMP]) 156 | cg.add(var.set_temp_outside_in(sens)) 157 | 158 | if CONF_HUMIDITY in config: 159 | sens = await sensor.new_sensor(config[CONF_HUMIDITY]) 160 | cg.add(var.set_humidity(sens)) 161 | 162 | if CONF_PRESSURE in config: 163 | sens = await sensor.new_sensor(config[CONF_PRESSURE]) 164 | cg.add(var.set_pressure(sens)) 165 | 166 | if CONF_CO2 in config: 167 | sens = await sensor.new_sensor(config[CONF_CO2]) 168 | cg.add(var.set_co2(sens)) 169 | 170 | if CONF_TVOC in config: 171 | sens = await sensor.new_sensor(config[CONF_TVOC]) 172 | cg.add(var.set_tvoc(sens)) 173 | 174 | if CONF_VOLTAGE in config: 175 | sens = await sensor.new_sensor(config[CONF_VOLTAGE]) 176 | cg.add(var.set_voltage(sens)) 177 | 178 | if CONF_FREQUENCY in config: 179 | sens = await sensor.new_sensor(config[CONF_FREQUENCY]) 180 | cg.add(var.set_frequency(sens)) 181 | if CONF_TIMESTAMP in config: 182 | sens = await sensor.new_sensor(config[CONF_TIMESTAMP]) 183 | cg.add(var.set_timestamp(sens)) 184 | -------------------------------------------------------------------------------- /esphome/components/prana_ble/prana_ble_hub.cpp: -------------------------------------------------------------------------------- 1 | #include "prana_ble_hub.h" 2 | #include "prana_ble_child.h" 3 | #include "prana_ble_const.h" 4 | 5 | namespace esphome { 6 | namespace prana_ble { 7 | 8 | 9 | 10 | static const LogString *prana_cmd_to_string(uint8_t command) { 11 | switch (command) { 12 | case CMD_POWER_OFF: 13 | return LOG_STR("OFF"); 14 | case CMD_POWER_ON: 15 | return LOG_STR("ON"); 16 | case CMD_BRIGHTNESS: 17 | return LOG_STR("CMD_BRIGHTNESS"); 18 | case CMD_HEATING: 19 | return LOG_STR("CMD_HEATING"); 20 | case CMD_NIGHT_MODE: 21 | return LOG_STR("CMD_NIGHT_MODE"); 22 | case CMD_HIGH_SPEED: 23 | return LOG_STR("CMD_HIGH_SPEED"); 24 | case CMD_FAN_LOCK: 25 | return LOG_STR("CMD_FAN_LOCK"); 26 | case CMD_FAN_OUT_OFF: 27 | return LOG_STR("CMD_FAN_OUT_OFF"); 28 | case CMD_FAN_OUT_SPEED_UP: 29 | return LOG_STR("CMD_FAN_OUT_SPEED_UP"); 30 | case CMD_FAN_OUT_SPEED_DOWN: 31 | return LOG_STR("CMD_FAN_OUT_SPEED_DOWN"); 32 | case CMD_WINTER_MODE: 33 | return LOG_STR("CMD_WINTER_MODE"); 34 | case CMD_AUTO_MODE: 35 | return LOG_STR("CMD_AUTO_MODE"); 36 | case CMD_FAN_SPEED_DOWN: 37 | return LOG_STR("CMD_FAN_SPEED_DOWN"); 38 | case CMD_FAN_SPEED_UP: 39 | return LOG_STR("CMD_FAN_SPEED_UP"); 40 | case CMD_FAN_IN_SPEED_UP: 41 | return LOG_STR("CMD_FAN_IN_SPEED_UP"); 42 | case CMD_FAN_IN_SPEED_DOWN: 43 | return LOG_STR("CMD_FAN_IN_SPEED_DOWN"); 44 | case CMD_FAN_IN_OFF: 45 | return LOG_STR("CMD_FAN_IN_OFF"); 46 | default: 47 | return LOG_STR("unknown"); 48 | } 49 | } 50 | 51 | /* Public */ 52 | bool PranaBLEHub::command_connect() 53 | { 54 | keep_connected_ = true; 55 | this->parent()->set_enabled(false); 56 | this->parent()->set_enabled(true); 57 | return true; 58 | } 59 | bool PranaBLEHub::command_disconnect() 60 | { 61 | if(keep_connected_ == true) 62 | { 63 | keep_connected_ = false; 64 | 65 | this->parent()->set_enabled(false); 66 | return true; 67 | } 68 | return false; 69 | } 70 | 71 | 72 | void PranaBLEHub::set_fans_locked(bool locked) 73 | { 74 | if(locked != fans_locked_) 75 | { 76 | 77 | send_command(CMD_FAN_LOCK, true); 78 | 79 | } 80 | } 81 | 82 | bool PranaBLEHub::get_fans_locked() 83 | { 84 | return fans_locked_; 85 | } 86 | 87 | bool PranaBLEHub::set_fan_speed(PranaFan fan, short new_speed, bool direct) 88 | { 89 | auto speed_diff = new_speed - get_fan_speed(fan); 90 | 91 | if(speed_diff == 0) 92 | return true; 93 | 94 | if(new_speed == 0) 95 | { 96 | return set_fan_off(fan); 97 | } 98 | status.speed = new_speed * 10; 99 | 100 | if(direct) 101 | { 102 | return set_fan_speed_direct(fan, new_speed-1); 103 | } 104 | else 105 | { 106 | for(int i=1; i <= abs(speed_diff); i++) 107 | { 108 | set_fan_step(fan, speed_diff > 0); 109 | delay(20); 110 | } 111 | } 112 | 113 | return true; 114 | } 115 | bool PranaBLEHub::set_fan_speed_direct(PranaFan fan, short new_speed) 116 | { 117 | switch(fan) 118 | { 119 | case FAN_BOTH: 120 | { 121 | return send_command(cmd_fan_speed[new_speed], true); 122 | break; 123 | } 124 | case FAN_IN: 125 | { 126 | return send_command(cmd_fan_in_speed[new_speed], true); 127 | break; 128 | } 129 | case FAN_OUT: 130 | { 131 | return send_command(cmd_fan_out_speed[new_speed], true); 132 | break; 133 | } 134 | } 135 | return false; 136 | } 137 | 138 | 139 | bool PranaBLEHub::set_auto_mode(PranaFanMode new_mode) 140 | { 141 | // we need to press auto button one or two times 142 | auto diff = new_mode - status.fan_mode; 143 | if(diff != 0) 144 | { 145 | if(diff == 2 || diff == -1) 146 | { 147 | ESP_LOGD(TAG, "Sending two commands"); 148 | send_command(CMD_AUTO_MODE, false); 149 | delay(10); 150 | } 151 | return command_auto_mode(); 152 | } 153 | return true; 154 | } 155 | 156 | 157 | bool PranaBLEHub::set_fan_step(PranaFan fan, bool up) 158 | { 159 | bool result = false; 160 | switch(fan) 161 | { 162 | case FAN_BOTH: 163 | { 164 | if(up) 165 | { 166 | return command_fans_speed_up(); 167 | } 168 | else 169 | { 170 | return command_fans_speed_down(); 171 | } 172 | break; 173 | } 174 | case FAN_IN: 175 | { 176 | if(up) 177 | { 178 | return command_fan_in_speed_up(); 179 | } 180 | else 181 | { 182 | return command_fan_in_speed_down(); 183 | } 184 | break; 185 | } 186 | 187 | case FAN_OUT: 188 | { 189 | if(up) 190 | { 191 | return command_fan_out_speed_up(); 192 | } 193 | else 194 | { 195 | return command_fan_out_speed_down(); 196 | } 197 | break; 198 | } 199 | } 200 | return false; 201 | } 202 | 203 | bool PranaBLEHub::set_fan_off(PranaFan fan) 204 | { 205 | switch(fan) 206 | { 207 | case FAN_BOTH: 208 | { 209 | if(command_poweroff()) 210 | status.enabled = false; 211 | break; 212 | } 213 | 214 | case FAN_IN: 215 | return command_fan_in_off(); 216 | case FAN_OUT: 217 | return command_fan_out_off(); 218 | } 219 | return false; 220 | } 221 | 222 | 223 | bool PranaBLEHub::set_fan_on(PranaFan fan) 224 | { 225 | switch(fan) 226 | { 227 | case FAN_BOTH: 228 | if(command_poweron()) 229 | status.enabled = true; 230 | break; 231 | case FAN_IN: 232 | return command_fan_in_off(); 233 | case FAN_OUT: 234 | return command_fan_out_off(); 235 | } 236 | return false; 237 | } 238 | 239 | short PranaBLEHub::get_fan_speed(PranaFan fan) 240 | { 241 | switch(fan) 242 | { 243 | case FAN_BOTH: 244 | return status.speed / 10; 245 | case FAN_IN: 246 | return status.speed_in / 10; 247 | case FAN_OUT: 248 | return status.speed_out / 10; 249 | } 250 | return 0; 251 | } 252 | 253 | 254 | short PranaBLEHub::get_brightness() 255 | { 256 | return log2(status.brightness) +1; 257 | } 258 | 259 | void PranaBLEHub::set_brightness(short value) 260 | { 261 | if(value == get_brightness()) 262 | return; 263 | 264 | auto diff = (value - get_brightness() + 6) % 6; 265 | for(int i=0; i < diff; ++i) { 266 | send_command(CMD_BRIGHTNESS, false); 267 | delay(20); 268 | } 269 | send_update_request(); 270 | 271 | } 272 | 273 | short PranaBLEHub::get_display_mode() 274 | { 275 | return status.display_mode; 276 | } 277 | 278 | void PranaBLEHub::set_display_mode(short mode) 279 | { 280 | auto current_mode = get_display_mode(); 281 | if(mode == current_mode) 282 | return; 283 | 284 | //minimizing cmd number by using left/right cmds 285 | auto modes = PranaDisplayMode::MODE_COUNT; 286 | auto diff_r = (mode - current_mode + modes) % modes; 287 | auto diff_l = (current_mode - mode + modes) % modes; 288 | 289 | if(diff_r <= diff_l) 290 | { 291 | //skipping UNUSED mode 292 | if(current_mode < PranaDisplayMode::UNUSED && current_mode + diff_r > PranaDisplayMode::UNUSED) 293 | { 294 | diff_r -= 1; 295 | } 296 | for(int i=0; i < diff_r; ++i) { 297 | 298 | send_command(CMD_DISPLAY_RIGHT, false); 299 | delay(20); 300 | } 301 | } 302 | else 303 | { 304 | //skipping UNUSED mode 305 | if(current_mode > PranaDisplayMode::UNUSED && current_mode - diff_l < PranaDisplayMode::UNUSED) 306 | { 307 | diff_l -= 1; 308 | } 309 | for(int i=0; i < diff_l; ++i) { 310 | send_command(CMD_DISPLAY_LEFT, false); 311 | delay(20); 312 | } 313 | } 314 | 315 | send_update_request(); 316 | } 317 | 318 | 319 | bool PranaBLEHub::send_command(uint8_t command, bool update) { 320 | PranaCmdPacket packet(command); 321 | auto status = this->send_packet(&packet, update); 322 | 323 | if (status) 324 | { 325 | 326 | ESP_LOGW(TAG, "[%s] writing button %s failed, status=%d", this->get_name().c_str(), 327 | LOG_STR_ARG(prana_cmd_to_string(command)), status); 328 | } else { 329 | ESP_LOGD(TAG, "[%s] writing button %s success", this->get_name().c_str(), 330 | LOG_STR_ARG(prana_cmd_to_string(command))); 331 | } 332 | return status == 0; 333 | } 334 | 335 | bool PranaBLEHub::send_command(PranaCommand command, bool update) { 336 | return send_command((uint8_t)command, update); 337 | } 338 | 339 | uint8_t PranaBLEHub::send_packet(PranaCmdPacket *pkt, bool update) 340 | { 341 | uint8_t result = this->send_data((uint8_t *) pkt, sizeof(PranaCmdPacket)); 342 | if(update && result!= 0) 343 | { 344 | delay(50); 345 | return this->send_update_request(); 346 | } 347 | 348 | return result; 349 | } 350 | 351 | uint8_t PranaBLEHub::send_data(uint8_t data[], uint8_t len) { 352 | ESP_LOGD(TAG, "Send data: %s", format_hex_pretty(data, len).c_str()); 353 | auto status = esp_ble_gattc_write_char( 354 | this->parent_->get_gattc_if(), 355 | this->parent()->get_conn_id(), 356 | this->char_handle_, 357 | len, 358 | data, 359 | ESP_GATT_WRITE_TYPE_RSP, 360 | ESP_GATT_AUTH_REQ_NONE 361 | ); 362 | return status; 363 | } 364 | 365 | uint8_t PranaBLEHub::send_update_request() 366 | { 367 | return send_data(const_cast(PRANA_STATE), sizeof(PRANA_STATE)); 368 | } 369 | 370 | 371 | /** Configures the local ESP BLE client to register (`true`) or unregister (`false`) for status notifications. */ 372 | uint8_t PranaBLEHub::set_notify_(const bool enable) { 373 | uint8_t status; 374 | if (enable) { 375 | status = esp_ble_gattc_register_for_notify( 376 | this->parent_->get_gattc_if(), 377 | this->parent_->get_remote_bda(), 378 | this->char_handle_ 379 | ); 380 | if (status) { 381 | ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); 382 | } 383 | } else { 384 | status = esp_ble_gattc_unregister_for_notify( 385 | this->parent_->get_gattc_if(), 386 | this->parent_->get_remote_bda(), 387 | this->char_handle_ 388 | ); 389 | if (status) { 390 | ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name().c_str(), status); 391 | } 392 | } 393 | return status; 394 | } 395 | 396 | bool PranaBLEHub::discover_characteristics_() { 397 | bool result = true; 398 | esphome::ble_client::BLECharacteristic *chr; 399 | 400 | 401 | if (!this->char_handle_) { 402 | chr = this->parent_->get_characteristic(PRANA_SERVICE_UUID, PRANA_CHAR_UUID); 403 | if (chr == nullptr) { 404 | ESP_LOGW(TAG, "[%s] No status service found at device, not a Prana..?", this->get_name().c_str()); 405 | result = false; 406 | } else { 407 | this->char_handle_ = chr->handle; 408 | } 409 | } 410 | 411 | if (!this->config_descr_status_) { 412 | // We also need to obtain the config descriptor for this handle. 413 | // Otherwise once we set node_state=Established, the parent will flush all handles/descriptors, and we won't be 414 | // able to look it up. 415 | auto *descr = this->parent_->get_config_descriptor(this->char_handle_); 416 | if (descr == nullptr) { 417 | ESP_LOGW(TAG, "No config descriptor for status handle 0x%x. Will not be able to receive status notifications", 418 | this->char_handle_); 419 | result = false; 420 | } else if (descr->uuid.get_uuid().len != ESP_UUID_LEN_16 || 421 | descr->uuid.get_uuid().uuid.uuid16 != ESP_GATT_UUID_CHAR_CLIENT_CONFIG) { 422 | ESP_LOGW(TAG, "Config descriptor 0x%x (uuid %s) is not a client config char uuid", this->char_handle_, 423 | descr->uuid.to_string().c_str()); 424 | result = false; 425 | } else { 426 | this->config_descr_status_ = descr->handle; 427 | } 428 | } 429 | 430 | ESP_LOGD(TAG, "[%s] Discovered service characteristics: ", this->get_name().c_str()); 431 | ESP_LOGD(TAG, " - Status char: 0x%x", this->char_handle_); 432 | ESP_LOGD(TAG, " - config descriptor: 0x%x", this->config_descr_status_); 433 | return result; 434 | } 435 | 436 | void PranaBLEHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, 437 | esp_ble_gattc_cb_param_t *param) { 438 | switch (event) { 439 | case ESP_GATTC_DISCONNECT_EVT: { 440 | ESP_LOGV(TAG, "Disconnected: reason=%d", param->disconnect.reason); 441 | this->status_set_warning(); 442 | this->dispatch_state_(false); 443 | break; 444 | } 445 | case ESP_GATTC_SEARCH_CMPL_EVT: { 446 | auto result = this->discover_characteristics_(); 447 | 448 | if (result) { 449 | ESP_LOGD(TAG, "[%s] Services complete: obtained char handles.", this->get_name().c_str()); 450 | this->node_state = espbt::ClientState::ESTABLISHED; 451 | this->set_notify_(true); 452 | 453 | this->dispatch_state_(true); 454 | } else { 455 | ESP_LOGW(TAG, "[%s] Failed discovering service characteristics.", this->get_name().c_str()); 456 | this->parent()->set_enabled(false); 457 | this->status_set_warning(); 458 | this->dispatch_state_(false); 459 | } 460 | break; 461 | } 462 | 463 | case ESP_GATTC_REG_FOR_NOTIFY_EVT: { 464 | // This event means that ESP received the request to enable notifications on the client side. But we also have to 465 | // tell the server that we want it to send notifications. Normally BLEClient parent would handle this 466 | // automatically, but as soon as we set our status to Established, the parent is going to purge all the 467 | // service/char/descriptor handles, and then get_config_descriptor() won't work anymore. There's no way to disable 468 | // the BLEClient parent behavior, so our only option is to write the handle anyway, and hope a double-write 469 | // doesn't break anything. 470 | 471 | if (param->reg_for_notify.handle != this->char_handle_) { 472 | ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x", 473 | this->get_name().c_str(), param->reg_for_notify.handle, this->char_handle_); 474 | break; 475 | } 476 | 477 | this->write_notify_config_descriptor_(true); 478 | this->last_notify_ = 0; 479 | this->force_refresh_ = true; 480 | break; 481 | } 482 | case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { 483 | // This event is not handled by the parent BLEClient, so we need to do this either way. 484 | if (param->unreg_for_notify.handle != this->char_handle_) { 485 | ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x", 486 | this->get_name().c_str(), param->unreg_for_notify.handle, this->char_handle_); 487 | break; 488 | } 489 | 490 | this->write_notify_config_descriptor_(false); 491 | this->last_notify_ = 0; 492 | // Now we wait until the next update() poll to re-register notify... 493 | break; 494 | } 495 | case ESP_GATTC_NOTIFY_EVT: { 496 | if (param->notify.conn_id != this->parent_->get_conn_id()) { 497 | ESP_LOGW(TAG, "[%s] Received notify event for unexpected parent conn: expect %x, got %x", 498 | this->get_name().c_str(), this->parent_->get_conn_id(), param->notify.conn_id); 499 | // FIXME: bug in BLEClient holding wrong conn_id. 500 | } 501 | 502 | if (param->notify.handle != this->char_handle_) { 503 | ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name().c_str(), 504 | this->char_handle_, param->notify.handle); 505 | break; 506 | } 507 | 508 | if(param->notify.value_len == 0 || param->notify.value_len < sizeof(PranaStatusPacket)) 509 | break; 510 | 511 | 512 | //uint8_t bytes[] = {0xBE,0xEF,0x05,0x01,0x67,0x11,0x96,0xBD,0x5A,0xC0,0x01,0xC0,0x20,0xC0,0x00,0xC0,0x00,0xC0,0x00,0x00,0x01,0xC0,0x00,0x40,0x01,0x40,0x32,0xC0,0x01,0xC0,0x14,0xC0,0x01,0xC0,0x14,0x00,0x00,0x80,0x00,0x00,0x00,0xC0,0x00,0x00,0x01,0x00,0x00,0x00,0x80,0x90,0x00,0x80,0x7D,0x00,0x80,0xC2,0x00,0x00,0x00,0x00,0xBD,0x82,0xA9,0x83,0x26,0x00,0x9B,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x82,0xFA,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0xC0,0x00,0x00,0x00,0x00,0x0A,0x13,0x00,0x3B,0xE9,0xFE,0x67,0x06,0x3F,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; 513 | //PranaStatusPacket* packet = (PranaStatusPacket*)bytes; 514 | PranaStatusPacket* packet = (PranaStatusPacket*)param->notify.value; 515 | if (packet != nullptr && packet->magic == PRANA_MAGIC) { 516 | this->status = *packet; 517 | ESP_LOGD(TAG, "[%s] Notifying %d children.", this->get_name().c_str(), this->children_.size()); 518 | for (auto *child : this->children_) { 519 | child->on_status(packet); 520 | } 521 | } 522 | this->last_notify_ = millis(); 523 | this->fans_locked_ = packet->fans_locked; 524 | ESP_LOGV(TAG, "Packet: %s", format_hex_pretty(param->notify.value, param->notify.value_len).c_str()); 525 | 526 | 527 | break; 528 | } 529 | default: 530 | ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name().c_str(), event); 531 | break; 532 | } 533 | } 534 | 535 | 536 | /** Reimplementation of BLEClient.gattc_event_handler() for ESP_GATTC_REG_FOR_NOTIFY_EVT. 537 | * 538 | * This is a copy of ble_client's automatic handling of `ESP_GATTC_REG_FOR_NOTIFY_EVT`, in order 539 | * to undo the same on unregister. It also allows us to maintain the config descriptor separately, 540 | * since the parent BLEClient is going to purge all descriptors once we set our connection status 541 | * to `Established`. 542 | */ 543 | uint8_t PranaBLEHub::write_notify_config_descriptor_(bool enable) { 544 | auto handle = this->config_descr_status_; 545 | if (handle == 0) { 546 | ESP_LOGW(TAG, "No descriptor found for notify of handle 0x%x", this->char_handle_); 547 | return -1; 548 | } 549 | 550 | // NOTE: BLEClient uses `uint8_t*` of length 1, but BLE spec requires 16 bits. 551 | uint16_t notify_en = enable ? 1 : 0; 552 | auto status = esp_ble_gattc_write_char_descr(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), handle, 553 | sizeof(notify_en), (uint8_t *) ¬ify_en, ESP_GATT_WRITE_TYPE_RSP, 554 | ESP_GATT_AUTH_REQ_NONE); 555 | if (status) { 556 | ESP_LOGW(TAG, "esp_ble_gattc_write_char_descr error, status=%d", status); 557 | return status; 558 | } 559 | ESP_LOGV(TAG, "[%s] wrote notify=%s to status config 0x%04x, for conn %d", this->get_name().c_str(), 560 | enable ? "true" : "false", handle, this->parent_->get_conn_id()); 561 | return ESP_GATT_OK; 562 | } 563 | 564 | 565 | /* Internal */ 566 | 567 | void PranaBLEHub::update() { this->dispatch_status_(); } 568 | 569 | void PranaBLEHub::dump_config() { 570 | ESP_LOGCONFIG(TAG, "Prana BLE Hub '%s'", this->get_name().c_str()); 571 | ESP_LOGCONFIG(TAG, " ble_client.app_id: %d", this->parent()->app_id); 572 | ESP_LOGCONFIG(TAG, " ble_client.conn_id: %d", this->parent()->get_conn_id()); 573 | LOG_UPDATE_INTERVAL(this); 574 | ESP_LOGCONFIG(TAG, " Child components (%d):", this->children_.size()); 575 | for (auto *child : this->children_) { 576 | ESP_LOGCONFIG(TAG, " - %s", child->describe().c_str()); 577 | } 578 | } 579 | 580 | void PranaBLEHub::dispatch_state_(bool is_ready) { 581 | for (auto *child : this->children_) { 582 | child->on_prana_state(is_ready); 583 | } 584 | } 585 | 586 | void PranaBLEHub::dispatch_status_() { 587 | //auto *status = this->codec_->get_status_packet(); 588 | if(!keep_connected_) 589 | { 590 | 591 | ESP_LOGV(TAG, "[%s] Connection disabled by user. Waiting for enabling...", this->get_name().c_str()); 592 | return; 593 | } 594 | 595 | if (!this->is_connected()) 596 | { 597 | ESP_LOGV(TAG, "[%s] Not connected, will not send status.", this->get_name().c_str()); 598 | } else { 599 | uint32_t diff = millis() - this->last_notify_; 600 | send_update_request(); 601 | 602 | if (this->timeout_ > 0 && diff > this->timeout_ && this->parent()->enabled) { 603 | ESP_LOGW(TAG, "[%s] Timed out after %i sec. Retrying...", this->get_name().c_str(), (int)this->timeout_); 604 | // set_enabled(false) will only close the connection if state != IDLE. 605 | this->parent()->set_state(espbt::ClientState::CONNECTING); 606 | this->parent()->set_enabled(false); 607 | this->parent()->set_enabled(true); 608 | } 609 | } 610 | } 611 | 612 | void PranaBLEHub::register_child(PranaBLEClient *obj) { 613 | this->children_.push_back(obj); 614 | obj->set_parent(this); 615 | } 616 | 617 | } // namespace prana_ble 618 | } // namespace esphome 619 | --------------------------------------------------------------------------------