mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Cover component for Tormatic and Novoferm garage doors (#5933)
This commit is contained in:
		| @@ -448,6 +448,7 @@ esphome/components/tmp102/* @timsavage | |||||||
| esphome/components/tmp1075/* @sybrenstuvel | esphome/components/tmp1075/* @sybrenstuvel | ||||||
| esphome/components/tmp117/* @Azimath | esphome/components/tmp117/* @Azimath | ||||||
| esphome/components/tof10120/* @wstrzalka | esphome/components/tof10120/* @wstrzalka | ||||||
|  | esphome/components/tormatic/* @ti-mo | ||||||
| esphome/components/toshiba/* @kbx81 | esphome/components/toshiba/* @kbx81 | ||||||
| esphome/components/touchscreen/* @jesserockz @nielsnl68 | esphome/components/touchscreen/* @jesserockz @nielsnl68 | ||||||
| esphome/components/tsl2591/* @wjcarpenter | esphome/components/tsl2591/* @wjcarpenter | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								esphome/components/tormatic/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								esphome/components/tormatic/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | CODEOWNERS = ["@ti-mo"] | ||||||
							
								
								
									
										47
									
								
								esphome/components/tormatic/cover.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								esphome/components/tormatic/cover.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | import esphome.codegen as cg | ||||||
|  | import esphome.config_validation as cv | ||||||
|  | from esphome.components import cover, uart | ||||||
|  | from esphome.const import ( | ||||||
|  |     CONF_CLOSE_DURATION, | ||||||
|  |     CONF_ID, | ||||||
|  |     CONF_OPEN_DURATION, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | tormatic_ns = cg.esphome_ns.namespace("tormatic") | ||||||
|  | Tormatic = tormatic_ns.class_("Tormatic", cover.Cover, cg.PollingComponent) | ||||||
|  |  | ||||||
|  | CONFIG_SCHEMA = ( | ||||||
|  |     cover.COVER_SCHEMA.extend(uart.UART_DEVICE_SCHEMA) | ||||||
|  |     .extend(cv.polling_component_schema("300ms")) | ||||||
|  |     .extend( | ||||||
|  |         { | ||||||
|  |             cv.GenerateID(): cv.declare_id(Tormatic), | ||||||
|  |             cv.Optional( | ||||||
|  |                 CONF_OPEN_DURATION, default="15s" | ||||||
|  |             ): cv.positive_time_period_milliseconds, | ||||||
|  |             cv.Optional( | ||||||
|  |                 CONF_CLOSE_DURATION, default="22s" | ||||||
|  |             ): cv.positive_time_period_milliseconds, | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( | ||||||
|  |     "tormatic", | ||||||
|  |     baud_rate=9600, | ||||||
|  |     require_tx=True, | ||||||
|  |     require_rx=True, | ||||||
|  |     data_bits=8, | ||||||
|  |     parity="NONE", | ||||||
|  |     stop_bits=1, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def to_code(config): | ||||||
|  |     var = cg.new_Pvariable(config[CONF_ID]) | ||||||
|  |     await cg.register_component(var, config) | ||||||
|  |     await cover.register_cover(var, config) | ||||||
|  |     await uart.register_uart_device(var, config) | ||||||
|  |  | ||||||
|  |     cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) | ||||||
|  |     cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) | ||||||
							
								
								
									
										355
									
								
								esphome/components/tormatic/tormatic_cover.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								esphome/components/tormatic/tormatic_cover.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,355 @@ | |||||||
|  | #include <vector> | ||||||
|  |  | ||||||
|  | #include "tormatic_cover.h" | ||||||
|  |  | ||||||
|  | using namespace std; | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace tormatic { | ||||||
|  |  | ||||||
|  | static const char *const TAG = "tormatic.cover"; | ||||||
|  |  | ||||||
|  | using namespace esphome::cover; | ||||||
|  |  | ||||||
|  | void Tormatic::setup() { | ||||||
|  |   auto restore = this->restore_state_(); | ||||||
|  |   if (restore.has_value()) { | ||||||
|  |     restore->apply(this); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Assume gate is closed without preexisting state. | ||||||
|  |   this->position = 0.0f; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | cover::CoverTraits Tormatic::get_traits() { | ||||||
|  |   auto traits = CoverTraits(); | ||||||
|  |   traits.set_supports_stop(true); | ||||||
|  |   traits.set_supports_position(true); | ||||||
|  |   traits.set_is_assumed_state(false); | ||||||
|  |   return traits; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void Tormatic::dump_config() { | ||||||
|  |   LOG_COVER("", "Tormatic Cover", this); | ||||||
|  |   this->check_uart_settings(9600, 1, uart::UART_CONFIG_PARITY_NONE, 8); | ||||||
|  |  | ||||||
|  |   ESP_LOGCONFIG(TAG, "  Open Duration: %.1fs", this->open_duration_ / 1e3f); | ||||||
|  |   ESP_LOGCONFIG(TAG, "  Close Duration: %.1fs", this->close_duration_ / 1e3f); | ||||||
|  |  | ||||||
|  |   auto restore = this->restore_state_(); | ||||||
|  |   if (restore.has_value()) { | ||||||
|  |     ESP_LOGCONFIG(TAG, "  Saved position %d%%", (int) (restore->position * 100.f)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void Tormatic::update() { this->request_gate_status_(); } | ||||||
|  |  | ||||||
|  | void Tormatic::loop() { | ||||||
|  |   auto o_status = this->read_gate_status_(); | ||||||
|  |   if (o_status) { | ||||||
|  |     auto status = o_status.value(); | ||||||
|  |  | ||||||
|  |     this->recalibrate_duration_(status); | ||||||
|  |     this->handle_gate_status_(status); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   this->recompute_position_(); | ||||||
|  |   this->stop_at_target_(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void Tormatic::control(const cover::CoverCall &call) { | ||||||
|  |   if (call.get_stop()) { | ||||||
|  |     this->send_gate_command_(PAUSED); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (call.get_position().has_value()) { | ||||||
|  |     auto pos = call.get_position().value(); | ||||||
|  |     this->control_position_(pos); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Wrap the Cover's publish_state with a rate limiter. Publishes if the last | ||||||
|  | // publish was longer than ratelimit milliseconds ago. 0 to disable. | ||||||
|  | void Tormatic::publish_state(bool save, uint32_t ratelimit) { | ||||||
|  |   auto now = millis(); | ||||||
|  |   if ((now - this->last_publish_time_) < ratelimit) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   this->last_publish_time_ = now; | ||||||
|  |  | ||||||
|  |   Cover::publish_state(save); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Recalibrate the gate's estimated open or close duration based on the | ||||||
|  | // actual time the operation took. | ||||||
|  | void Tormatic::recalibrate_duration_(GateStatus s) { | ||||||
|  |   if (this->current_status_ == s) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   auto now = millis(); | ||||||
|  |   auto old = this->current_status_; | ||||||
|  |  | ||||||
|  |   // Gate paused halfway through opening or closing, invalidate the start time | ||||||
|  |   // of the current operation. Close/open durations can only be accurately | ||||||
|  |   // calibrated on full open or close cycle due to motor acceleration. | ||||||
|  |   if (s == PAUSED) { | ||||||
|  |     ESP_LOGD(TAG, "Gate paused, clearing direction start time"); | ||||||
|  |     this->direction_start_time_ = 0; | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Record the start time of a state transition if the gate was in the fully | ||||||
|  |   // open or closed position before the command. | ||||||
|  |   if ((old == CLOSED && s == OPENING) || (old == OPENED && s == CLOSING)) { | ||||||
|  |     ESP_LOGD(TAG, "Gate started moving from fully open or closed state"); | ||||||
|  |     this->direction_start_time_ = now; | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // The gate was resumed from a paused state, don't attempt recalibration. | ||||||
|  |   if (this->direction_start_time_ == 0) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (s == OPENED) { | ||||||
|  |     this->open_duration_ = now - this->direction_start_time_; | ||||||
|  |     ESP_LOGI(TAG, "Recalibrated the gate's open duration to %dms", this->open_duration_); | ||||||
|  |   } | ||||||
|  |   if (s == CLOSED) { | ||||||
|  |     this->close_duration_ = now - this->direction_start_time_; | ||||||
|  |     ESP_LOGI(TAG, "Recalibrated the gate's close duration to %dms", this->close_duration_); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   this->direction_start_time_ = 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Set the Cover's internal state based on a status message | ||||||
|  | // received from the unit. | ||||||
|  | void Tormatic::handle_gate_status_(GateStatus s) { | ||||||
|  |   if (this->current_status_ == s) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ESP_LOGI(TAG, "Status changed from %s to %s", gate_status_to_str(this->current_status_), gate_status_to_str(s)); | ||||||
|  |  | ||||||
|  |   switch (s) { | ||||||
|  |     case OPENED: | ||||||
|  |       // The Novoferm 423 doesn't respond to the first 'Close' command after | ||||||
|  |       // being opened completely. Sending a pause command after opening fixes | ||||||
|  |       // that. | ||||||
|  |       this->send_gate_command_(PAUSED); | ||||||
|  |  | ||||||
|  |       this->position = COVER_OPEN; | ||||||
|  |       break; | ||||||
|  |     case CLOSED: | ||||||
|  |       this->position = COVER_CLOSED; | ||||||
|  |       break; | ||||||
|  |     default: | ||||||
|  |       break; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   this->current_status_ = s; | ||||||
|  |   this->current_operation = gate_status_to_cover_operation(s); | ||||||
|  |  | ||||||
|  |   this->publish_state(true); | ||||||
|  |  | ||||||
|  |   // This timestamp is used to generate position deltas on every loop() while | ||||||
|  |   // the gate is moving. Bump it on each state transition so the first tick | ||||||
|  |   // doesn't generate a huge delta. | ||||||
|  |   this->last_recompute_time_ = millis(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Recompute the gate's position and publish the results while | ||||||
|  | // the gate is moving. No-op when the gate is idle. | ||||||
|  | void Tormatic::recompute_position_() { | ||||||
|  |   if (this->current_operation == COVER_OPERATION_IDLE) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const uint32_t now = millis(); | ||||||
|  |   uint32_t diff = now - this->last_recompute_time_; | ||||||
|  |  | ||||||
|  |   auto direction = +1.0f; | ||||||
|  |   uint32_t duration = this->open_duration_; | ||||||
|  |   if (this->current_operation == COVER_OPERATION_CLOSING) { | ||||||
|  |     direction = -1.0f; | ||||||
|  |     duration = this->close_duration_; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   auto delta = direction * diff / duration; | ||||||
|  |  | ||||||
|  |   this->position = clamp(this->position + delta, COVER_CLOSED, COVER_OPEN); | ||||||
|  |  | ||||||
|  |   this->last_recompute_time_ = now; | ||||||
|  |  | ||||||
|  |   this->publish_state(true, 250); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Start moving the gate in the direction of the target position. | ||||||
|  | void Tormatic::control_position_(float target) { | ||||||
|  |   if (target == this->position) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (target == COVER_OPEN) { | ||||||
|  |     ESP_LOGI(TAG, "Fully opening gate"); | ||||||
|  |     this->send_gate_command_(OPENED); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   if (target == COVER_CLOSED) { | ||||||
|  |     ESP_LOGI(TAG, "Fully closing gate"); | ||||||
|  |     this->send_gate_command_(CLOSED); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Don't set target position when fully opening or closing the gate, the gate | ||||||
|  |   // stops automatically when it reaches the configured open/closed positions. | ||||||
|  |   this->target_position_ = target; | ||||||
|  |  | ||||||
|  |   if (target > this->position) { | ||||||
|  |     ESP_LOGI(TAG, "Opening gate towards %.1f", target); | ||||||
|  |     this->send_gate_command_(OPENED); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (target < this->position) { | ||||||
|  |     ESP_LOGI(TAG, "Closing gate towards %.1f", target); | ||||||
|  |     this->send_gate_command_(CLOSED); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Stop the gate if it is moving at or beyond its target position. Target | ||||||
|  | // position is only set when the gate is requested to move to a halfway | ||||||
|  | // position. | ||||||
|  | void Tormatic::stop_at_target_() { | ||||||
|  |   if (this->current_operation == COVER_OPERATION_IDLE) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   if (!this->target_position_) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   auto target = this->target_position_.value(); | ||||||
|  |  | ||||||
|  |   if (this->current_operation == COVER_OPERATION_OPENING && this->position < target) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   if (this->current_operation == COVER_OPERATION_CLOSING && this->position > target) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   this->send_gate_command_(PAUSED); | ||||||
|  |   this->target_position_.reset(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Read a GateStatus from the unit. The unit only sends messages in response to | ||||||
|  | // status requests or commands, so a message needs to be sent first. | ||||||
|  | optional<GateStatus> Tormatic::read_gate_status_() { | ||||||
|  |   if (this->available() < sizeof(MessageHeader)) { | ||||||
|  |     return {}; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   auto o_hdr = this->read_data_<MessageHeader>(); | ||||||
|  |   if (!o_hdr) { | ||||||
|  |     ESP_LOGE(TAG, "Timeout reading message header"); | ||||||
|  |     return {}; | ||||||
|  |   } | ||||||
|  |   auto hdr = o_hdr.value(); | ||||||
|  |  | ||||||
|  |   switch (hdr.type) { | ||||||
|  |     case STATUS: { | ||||||
|  |       if (hdr.payload_size() != sizeof(StatusReply)) { | ||||||
|  |         ESP_LOGE(TAG, "Header specifies payload size %d but size of StatusReply is %d", hdr.payload_size(), | ||||||
|  |                  sizeof(StatusReply)); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Read a StatusReply requested by update(). | ||||||
|  |       auto o_status = this->read_data_<StatusReply>(); | ||||||
|  |       if (!o_status) { | ||||||
|  |         return {}; | ||||||
|  |       } | ||||||
|  |       auto status = o_status.value(); | ||||||
|  |  | ||||||
|  |       return status.state; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     case COMMAND: | ||||||
|  |       // Commands initiated by control() are simply echoed back by the unit, but | ||||||
|  |       // don't guarantee that the unit's internal state has been transitioned, | ||||||
|  |       // nor that the motor started moving. A subsequent status request may | ||||||
|  |       // still return the previous state. Discard these messages, don't use them | ||||||
|  |       // to drive the Cover state machine. | ||||||
|  |       break; | ||||||
|  |  | ||||||
|  |     default: | ||||||
|  |       // Unknown message type, drain the remaining amount of bytes specified in | ||||||
|  |       // the header. | ||||||
|  |       ESP_LOGE(TAG, "Reading remaining %d payload bytes of unknown type 0x%x", hdr.payload_size(), hdr.type); | ||||||
|  |       break; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Drain any unhandled payload bytes described by the message header, if any. | ||||||
|  |   this->drain_rx_(hdr.payload_size()); | ||||||
|  |  | ||||||
|  |   return {}; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Send a message to the unit requesting the gate's status. | ||||||
|  | void Tormatic::request_gate_status_() { | ||||||
|  |   ESP_LOGV(TAG, "Requesting gate status"); | ||||||
|  |   StatusRequest req(GATE); | ||||||
|  |   this->send_message_(STATUS, req); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Send a message to the unit issuing a command. | ||||||
|  | void Tormatic::send_gate_command_(GateStatus s) { | ||||||
|  |   ESP_LOGI(TAG, "Sending gate command %s", gate_status_to_str(s)); | ||||||
|  |   CommandRequestReply req(s); | ||||||
|  |   this->send_message_(COMMAND, req); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | template<typename T> void Tormatic::send_message_(MessageType t, T req) { | ||||||
|  |   MessageHeader hdr(t, ++this->seq_tx_, sizeof(req)); | ||||||
|  |  | ||||||
|  |   auto out = serialize(hdr); | ||||||
|  |   auto reqv = serialize(req); | ||||||
|  |   out.insert(out.end(), reqv.begin(), reqv.end()); | ||||||
|  |  | ||||||
|  |   this->write_array(out); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | template<typename T> optional<T> Tormatic::read_data_() { | ||||||
|  |   T obj; | ||||||
|  |   uint32_t start = millis(); | ||||||
|  |  | ||||||
|  |   auto ok = this->read_array((uint8_t *) &obj, sizeof(obj)); | ||||||
|  |   if (!ok) { | ||||||
|  |     // Couldn't read object successfully, timeout? | ||||||
|  |     return {}; | ||||||
|  |   } | ||||||
|  |   obj.byteswap(); | ||||||
|  |  | ||||||
|  |   ESP_LOGV(TAG, "Read %s in %d ms", obj.print().c_str(), millis() - start); | ||||||
|  |   return obj; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Drain up to n amount of bytes from the uart rx buffer. | ||||||
|  | void Tormatic::drain_rx_(uint16_t n) { | ||||||
|  |   uint8_t data; | ||||||
|  |   uint16_t count = 0; | ||||||
|  |   while (this->available()) { | ||||||
|  |     this->read_byte(&data); | ||||||
|  |     count++; | ||||||
|  |  | ||||||
|  |     if (n > 0 && count >= n) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | }  // namespace tormatic | ||||||
|  | }  // namespace esphome | ||||||
							
								
								
									
										60
									
								
								esphome/components/tormatic/tormatic_cover.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								esphome/components/tormatic/tormatic_cover.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | |||||||
|  | #pragma once | ||||||
|  |  | ||||||
|  | #include "esphome/components/uart/uart.h" | ||||||
|  | #include "esphome/components/cover/cover.h" | ||||||
|  |  | ||||||
|  | #include "tormatic_protocol.h" | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace tormatic { | ||||||
|  |  | ||||||
|  | using namespace esphome::cover; | ||||||
|  |  | ||||||
|  | class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingComponent { | ||||||
|  |  public: | ||||||
|  |   void setup() override; | ||||||
|  |   void loop() override; | ||||||
|  |   void update() override; | ||||||
|  |   void dump_config() override; | ||||||
|  |   float get_setup_priority() const override { return setup_priority::DATA; }; | ||||||
|  |  | ||||||
|  |   void set_open_duration(uint32_t duration) { this->open_duration_ = duration; } | ||||||
|  |   void set_close_duration(uint32_t duration) { this->close_duration_ = duration; } | ||||||
|  |  | ||||||
|  |   void publish_state(bool save = true, uint32_t ratelimit = 0); | ||||||
|  |  | ||||||
|  |   cover::CoverTraits get_traits() override; | ||||||
|  |  | ||||||
|  |  protected: | ||||||
|  |   void control(const cover::CoverCall &call) override; | ||||||
|  |  | ||||||
|  |   void recalibrate_duration_(GateStatus s); | ||||||
|  |   void recompute_position_(); | ||||||
|  |   void control_position_(float target); | ||||||
|  |   void stop_at_target_(); | ||||||
|  |  | ||||||
|  |   template<typename T> void send_message_(MessageType t, T r); | ||||||
|  |   template<typename T> optional<T> read_data_(); | ||||||
|  |   void drain_rx_(uint16_t n = 0); | ||||||
|  |  | ||||||
|  |   void request_gate_status_(); | ||||||
|  |   optional<GateStatus> read_gate_status_(); | ||||||
|  |  | ||||||
|  |   void send_gate_command_(GateStatus s); | ||||||
|  |   void handle_gate_status_(GateStatus s); | ||||||
|  |  | ||||||
|  |   uint32_t seq_tx_{0}; | ||||||
|  |  | ||||||
|  |   GateStatus current_status_{PAUSED}; | ||||||
|  |  | ||||||
|  |   uint32_t open_duration_{0}; | ||||||
|  |   uint32_t close_duration_{0}; | ||||||
|  |   uint32_t last_publish_time_{0}; | ||||||
|  |   uint32_t last_recompute_time_{0}; | ||||||
|  |   uint32_t direction_start_time_{0}; | ||||||
|  |   GateStatus next_command_{OPENED}; | ||||||
|  |   optional<float> target_position_{}; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | }  // namespace tormatic | ||||||
|  | }  // namespace esphome | ||||||
							
								
								
									
										211
									
								
								esphome/components/tormatic/tormatic_protocol.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								esphome/components/tormatic/tormatic_protocol.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,211 @@ | |||||||
|  | #pragma once | ||||||
|  |  | ||||||
|  | #include "esphome/components/cover/cover.h" | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * This file implements the UART protocol spoken over the on-board Micro-USB | ||||||
|  |  * (Type B) connector of Tormatic and Novoferm gates manufactured as of 2016. | ||||||
|  |  * All communication is initiated by the component. The unit doesn't send data | ||||||
|  |  * without being asked first. | ||||||
|  |  * | ||||||
|  |  * There are two main message types: status requests and commands. | ||||||
|  |  * | ||||||
|  |  * Querying the gate's status: | ||||||
|  |  * | ||||||
|  |  * | sequence  |        length       |    type   |       payload       | | ||||||
|  |  * | 0xF3 0xCB | 0x00 0x00 0x00 0x06 | 0x01 0x04 | 0x00 0x0A 0x00 0x01 | | ||||||
|  |  * | 0xF3 0xCB | 0x00 0x00 0x00 0x05 | 0x01 0x04 | 0x02 0x03 0x00      | | ||||||
|  |  * | ||||||
|  |  * This request asks for the gate status (0x0A); the only other value observed | ||||||
|  |  * in the request was 0x0B, but replies were always zero. Presumably this | ||||||
|  |  * queries another sensor on the unit like a safety breaker, but this is not | ||||||
|  |  * relevant for an esphome cover component. | ||||||
|  |  * | ||||||
|  |  * The second byte of the reply is set to 0x03 when the gate is in fully open | ||||||
|  |  * position. Other valid values for the second byte are: (0x0) Paused, (0x1) | ||||||
|  |  * Closed, (0x2) Ventilating, (0x3) Opened, (0x4) Opening, (0x5) Closing. The | ||||||
|  |  * meaning of the other bytes is currently unknown and ignored by the component. | ||||||
|  |  * | ||||||
|  |  * Controlling the gate: | ||||||
|  |  * | ||||||
|  |  * | sequence  |        length       |    type   |       payload       | | ||||||
|  |  * | 0x40 0xFF | 0x00 0x00 0x00 0x06 | 0x01 0x06 | 0x00 0x0A 0x00 0x03 | | ||||||
|  |  * | 0x40 0xFF | 0x00 0x00 0x00 0x06 | 0x01 0x06 | 0x00 0x0A 0x00 0x03 | | ||||||
|  |  * | ||||||
|  |  * The unit acks any commands by echoing back the message in full. However, | ||||||
|  |  * this does _not_ mean the gate has started closing. The component only | ||||||
|  |  * considers status replies as authoritative and simply fires off commands, | ||||||
|  |  * ignoring the echoed messages. | ||||||
|  |  * | ||||||
|  |  * The payload structure is as follows: [0x00, 0x0A] (gate), followed by | ||||||
|  |  * one of the states normally carried in status replies: (0x0) Pause, (0x1) | ||||||
|  |  * Close, (0x2) Ventilate (open ~20%), (0x3) Open/high-torque reverse. The | ||||||
|  |  * protocol implementation in this file simply reuses the GateStatus enum | ||||||
|  |  * for this purpose. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace tormatic { | ||||||
|  |  | ||||||
|  | using namespace esphome::cover; | ||||||
|  |  | ||||||
|  | // MessageType is the type of message that follows the MessageHeader. | ||||||
|  | enum MessageType : uint16_t { | ||||||
|  |   STATUS = 0x0104, | ||||||
|  |   COMMAND = 0x0106, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | inline const char *message_type_to_str(MessageType t) { | ||||||
|  |   switch (t) { | ||||||
|  |     case STATUS: | ||||||
|  |       return "Status"; | ||||||
|  |     case COMMAND: | ||||||
|  |       return "Command"; | ||||||
|  |     default: | ||||||
|  |       return "Unknown"; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // MessageHeader appears at the start of every message, both requests and replies. | ||||||
|  | struct MessageHeader { | ||||||
|  |   uint16_t seq; | ||||||
|  |   uint32_t len; | ||||||
|  |   MessageType type; | ||||||
|  |  | ||||||
|  |   MessageHeader() = default; | ||||||
|  |   MessageHeader(MessageType type, uint16_t seq, uint32_t payload_size) { | ||||||
|  |     this->type = type; | ||||||
|  |     this->seq = seq; | ||||||
|  |     // len includes the length of the type field. It was | ||||||
|  |     // included in MessageHeader to avoid having to parse | ||||||
|  |     // it as part of the payload. | ||||||
|  |     this->len = payload_size + sizeof(this->type); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   std::string print() { | ||||||
|  |     return str_sprintf("MessageHeader: seq %d, len %d, type %s", this->seq, this->len, message_type_to_str(this->type)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void byteswap() { | ||||||
|  |     this->len = convert_big_endian(this->len); | ||||||
|  |     this->seq = convert_big_endian(this->seq); | ||||||
|  |     this->type = convert_big_endian(this->type); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // payload_size returns the amount of payload bytes to be read from the uart | ||||||
|  |   // buffer after reading the header. | ||||||
|  |   uint32_t payload_size() { return this->len - sizeof(this->type); } | ||||||
|  | } __attribute__((packed)); | ||||||
|  |  | ||||||
|  | // StatusType denotes which 'page' of information needs to be retrieved. | ||||||
|  | // On my Novoferm 423, only the GATE status type returns values, Unknown | ||||||
|  | // only contains zeroes. | ||||||
|  | enum StatusType : uint16_t { | ||||||
|  |   GATE = 0x0A, | ||||||
|  |   UNKNOWN = 0x0B, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // GateStatus defines the current state of the gate, received in a StatusReply | ||||||
|  | // and sent in a Command. | ||||||
|  | enum GateStatus : uint8_t { | ||||||
|  |   PAUSED, | ||||||
|  |   CLOSED, | ||||||
|  |   VENTILATING, | ||||||
|  |   OPENED, | ||||||
|  |   OPENING, | ||||||
|  |   CLOSING, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | inline CoverOperation gate_status_to_cover_operation(GateStatus s) { | ||||||
|  |   switch (s) { | ||||||
|  |     case OPENING: | ||||||
|  |       return COVER_OPERATION_OPENING; | ||||||
|  |     case CLOSING: | ||||||
|  |       return COVER_OPERATION_CLOSING; | ||||||
|  |     case OPENED: | ||||||
|  |     case CLOSED: | ||||||
|  |     case PAUSED: | ||||||
|  |     case VENTILATING: | ||||||
|  |       return COVER_OPERATION_IDLE; | ||||||
|  |   } | ||||||
|  |   return COVER_OPERATION_IDLE; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | inline const char *gate_status_to_str(GateStatus s) { | ||||||
|  |   switch (s) { | ||||||
|  |     case PAUSED: | ||||||
|  |       return "Paused"; | ||||||
|  |     case CLOSED: | ||||||
|  |       return "Closed"; | ||||||
|  |     case VENTILATING: | ||||||
|  |       return "Ventilating"; | ||||||
|  |     case OPENED: | ||||||
|  |       return "Opened"; | ||||||
|  |     case OPENING: | ||||||
|  |       return "Opening"; | ||||||
|  |     case CLOSING: | ||||||
|  |       return "Closing"; | ||||||
|  |     default: | ||||||
|  |       return "Unknown"; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // A StatusRequest is sent to request the gate's current status. | ||||||
|  | struct StatusRequest { | ||||||
|  |   StatusType type; | ||||||
|  |   uint16_t trailer = 0x1; | ||||||
|  |  | ||||||
|  |   StatusRequest() = default; | ||||||
|  |   StatusRequest(StatusType type) { this->type = type; } | ||||||
|  |  | ||||||
|  |   void byteswap() { | ||||||
|  |     this->type = convert_big_endian(this->type); | ||||||
|  |     this->trailer = convert_big_endian(this->trailer); | ||||||
|  |   } | ||||||
|  | } __attribute__((packed)); | ||||||
|  |  | ||||||
|  | // StatusReply is received from the unit in response to a StatusRequest. | ||||||
|  | struct StatusReply { | ||||||
|  |   uint8_t ack = 0x2; | ||||||
|  |   GateStatus state; | ||||||
|  |   uint8_t trailer = 0x0; | ||||||
|  |  | ||||||
|  |   std::string print() { return str_sprintf("StatusReply: state %s", gate_status_to_str(this->state)); } | ||||||
|  |  | ||||||
|  |   void byteswap(){}; | ||||||
|  | } __attribute__((packed)); | ||||||
|  |  | ||||||
|  | // Serialize the given object to a new byte vector. | ||||||
|  | // Invokes the object's byteswap() method. | ||||||
|  | template<typename T> std::vector<uint8_t> serialize(T obj) { | ||||||
|  |   obj.byteswap(); | ||||||
|  |  | ||||||
|  |   std::vector<uint8_t> out(sizeof(T)); | ||||||
|  |   memcpy(out.data(), &obj, sizeof(T)); | ||||||
|  |  | ||||||
|  |   return out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Command tells the gate to start or stop moving. | ||||||
|  | // It is echoed back by the unit on success. | ||||||
|  | struct CommandRequestReply { | ||||||
|  |   // The part of the unit to control. For now only the gate is supported. | ||||||
|  |   StatusType type = GATE; | ||||||
|  |   uint8_t pad = 0x0; | ||||||
|  |   // The desired state: | ||||||
|  |   // PAUSED = stop | ||||||
|  |   // VENTILATING = move to ~20% open | ||||||
|  |   // CLOSED = close | ||||||
|  |   // OPENED = open/high-torque reverse when closing | ||||||
|  |   GateStatus state; | ||||||
|  |  | ||||||
|  |   CommandRequestReply() = default; | ||||||
|  |   CommandRequestReply(GateStatus state) { this->state = state; } | ||||||
|  |  | ||||||
|  |   std::string print() { return str_sprintf("CommandRequestReply: state %s", gate_status_to_str(this->state)); } | ||||||
|  |  | ||||||
|  |   void byteswap() { this->type = convert_big_endian(this->type); } | ||||||
|  | } __attribute__((packed)); | ||||||
|  |  | ||||||
|  | }  // namespace tormatic | ||||||
|  | }  // namespace esphome | ||||||
							
								
								
									
										13
									
								
								tests/components/tormatic/common.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								tests/components/tormatic/common.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | uart: | ||||||
|  |   - id: uart_tormatic | ||||||
|  |     tx_pin: ${tx_pin} | ||||||
|  |     rx_pin: ${rx_pin} | ||||||
|  |     baud_rate: 9600 | ||||||
|  |  | ||||||
|  | cover: | ||||||
|  |   - platform: tormatic | ||||||
|  |     uart_id: uart_tormatic | ||||||
|  |     id: tormatic_garage_door | ||||||
|  |     name: Tormatic Garage Door | ||||||
|  |     open_duration: 15s | ||||||
|  |     close_duration: 22s | ||||||
							
								
								
									
										5
									
								
								tests/components/tormatic/test.esp32-ard.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/tormatic/test.esp32-ard.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | substitutions: | ||||||
|  |   tx_pin: GPIO17 | ||||||
|  |   rx_pin: GPIO16 | ||||||
|  |  | ||||||
|  | <<: !include common.yaml | ||||||
							
								
								
									
										5
									
								
								tests/components/tormatic/test.esp32-c3-ard.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/tormatic/test.esp32-c3-ard.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | substitutions: | ||||||
|  |   tx_pin: GPIO4 | ||||||
|  |   rx_pin: GPIO5 | ||||||
|  |  | ||||||
|  | <<: !include common.yaml | ||||||
							
								
								
									
										5
									
								
								tests/components/tormatic/test.esp32-c3-idf.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/tormatic/test.esp32-c3-idf.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | substitutions: | ||||||
|  |   tx_pin: GPIO4 | ||||||
|  |   rx_pin: GPIO5 | ||||||
|  |  | ||||||
|  | <<: !include common.yaml | ||||||
							
								
								
									
										5
									
								
								tests/components/tormatic/test.esp32-idf.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/tormatic/test.esp32-idf.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | substitutions: | ||||||
|  |   tx_pin: GPIO17 | ||||||
|  |   rx_pin: GPIO16 | ||||||
|  |  | ||||||
|  | <<: !include common.yaml | ||||||
							
								
								
									
										5
									
								
								tests/components/tormatic/test.esp8266-ard.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/tormatic/test.esp8266-ard.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | substitutions: | ||||||
|  |   tx_pin: GPIO4 | ||||||
|  |   rx_pin: GPIO5 | ||||||
|  |  | ||||||
|  | <<: !include common.yaml | ||||||
							
								
								
									
										5
									
								
								tests/components/tormatic/test.rp2040-ard.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/tormatic/test.rp2040-ard.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | substitutions: | ||||||
|  |   tx_pin: GPIO4 | ||||||
|  |   rx_pin: GPIO5 | ||||||
|  |  | ||||||
|  | <<: !include common.yaml | ||||||
		Reference in New Issue
	
	Block a user