diff --git a/CODEOWNERS b/CODEOWNERS index c630db7948..3db48c903a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -378,6 +378,7 @@ esphome/components/tmp102/* @timsavage esphome/components/tmp1075/* @sybrenstuvel esphome/components/tmp117/* @Azimath esphome/components/tof10120/* @wstrzalka +esphome/components/tormatic/* @ti-mo esphome/components/toshiba/* @kbx81 esphome/components/touchscreen/* @jesserockz @nielsnl68 esphome/components/tsl2591/* @wjcarpenter diff --git a/esphome/components/tormatic/__init__.py b/esphome/components/tormatic/__init__.py new file mode 100644 index 0000000000..7f3f05a3cd --- /dev/null +++ b/esphome/components/tormatic/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@ti-mo"] diff --git a/esphome/components/tormatic/cover.py b/esphome/components/tormatic/cover.py new file mode 100644 index 0000000000..f1cfe09a05 --- /dev/null +++ b/esphome/components/tormatic/cover.py @@ -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])) diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp new file mode 100644 index 0000000000..35224c8ec7 --- /dev/null +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -0,0 +1,355 @@ +#include + +#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 Tormatic::read_gate_status_() { + if (this->available() < sizeof(MessageHeader)) { + return {}; + } + + auto o_hdr = this->read_data_(); + 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_(); + 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 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 optional 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 diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h new file mode 100644 index 0000000000..33a2e1db8f --- /dev/null +++ b/esphome/components/tormatic/tormatic_cover.h @@ -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 void send_message_(MessageType t, T r); + template optional read_data_(); + void drain_rx_(uint16_t n = 0); + + void request_gate_status_(); + optional 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 target_position_{}; +}; + +} // namespace tormatic +} // namespace esphome diff --git a/esphome/components/tormatic/tormatic_protocol.h b/esphome/components/tormatic/tormatic_protocol.h new file mode 100644 index 0000000000..e26535e985 --- /dev/null +++ b/esphome/components/tormatic/tormatic_protocol.h @@ -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 std::vector serialize(T obj) { + obj.byteswap(); + + std::vector 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 diff --git a/tests/test4.yaml b/tests/test4.yaml index 993ce126a8..06ecb8d005 100644 --- a/tests/test4.yaml +++ b/tests/test4.yaml @@ -101,6 +101,14 @@ uart: allow_other_uses: true baud_rate: 1200 parity: EVEN + - id: uart_tormatic + tx_pin: + allow_other_uses: true + number: GPIO25 + rx_pin: + allow_other_uses: true + number: GPIO26 + baud_rate: 9600 ota: safe_mode: true @@ -587,6 +595,12 @@ cover: name: Garage Door open_duration: 14s close_duration: 14s + - platform: tormatic + uart_id: uart_tormatic + id: tormatic_garage_door + name: Tormatic Garage Door + open_duration: 15s + close_duration: 22s display: - platform: addressable_light