diff --git a/CODEOWNERS b/CODEOWNERS
index 74cda0fe9c..204d2b58bd 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -448,6 +448,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 <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
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<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
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<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
diff --git a/tests/components/tormatic/common.yaml b/tests/components/tormatic/common.yaml
new file mode 100644
index 0000000000..0f1b33ac12
--- /dev/null
+++ b/tests/components/tormatic/common.yaml
@@ -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
diff --git a/tests/components/tormatic/test.esp32-ard.yaml b/tests/components/tormatic/test.esp32-ard.yaml
new file mode 100644
index 0000000000..f486544afa
--- /dev/null
+++ b/tests/components/tormatic/test.esp32-ard.yaml
@@ -0,0 +1,5 @@
+substitutions:
+  tx_pin: GPIO17
+  rx_pin: GPIO16
+
+<<: !include common.yaml
diff --git a/tests/components/tormatic/test.esp32-c3-ard.yaml b/tests/components/tormatic/test.esp32-c3-ard.yaml
new file mode 100644
index 0000000000..b516342f3b
--- /dev/null
+++ b/tests/components/tormatic/test.esp32-c3-ard.yaml
@@ -0,0 +1,5 @@
+substitutions:
+  tx_pin: GPIO4
+  rx_pin: GPIO5
+
+<<: !include common.yaml
diff --git a/tests/components/tormatic/test.esp32-c3-idf.yaml b/tests/components/tormatic/test.esp32-c3-idf.yaml
new file mode 100644
index 0000000000..b516342f3b
--- /dev/null
+++ b/tests/components/tormatic/test.esp32-c3-idf.yaml
@@ -0,0 +1,5 @@
+substitutions:
+  tx_pin: GPIO4
+  rx_pin: GPIO5
+
+<<: !include common.yaml
diff --git a/tests/components/tormatic/test.esp32-idf.yaml b/tests/components/tormatic/test.esp32-idf.yaml
new file mode 100644
index 0000000000..f486544afa
--- /dev/null
+++ b/tests/components/tormatic/test.esp32-idf.yaml
@@ -0,0 +1,5 @@
+substitutions:
+  tx_pin: GPIO17
+  rx_pin: GPIO16
+
+<<: !include common.yaml
diff --git a/tests/components/tormatic/test.esp8266-ard.yaml b/tests/components/tormatic/test.esp8266-ard.yaml
new file mode 100644
index 0000000000..b516342f3b
--- /dev/null
+++ b/tests/components/tormatic/test.esp8266-ard.yaml
@@ -0,0 +1,5 @@
+substitutions:
+  tx_pin: GPIO4
+  rx_pin: GPIO5
+
+<<: !include common.yaml
diff --git a/tests/components/tormatic/test.rp2040-ard.yaml b/tests/components/tormatic/test.rp2040-ard.yaml
new file mode 100644
index 0000000000..b516342f3b
--- /dev/null
+++ b/tests/components/tormatic/test.rp2040-ard.yaml
@@ -0,0 +1,5 @@
+substitutions:
+  tx_pin: GPIO4
+  rx_pin: GPIO5
+
+<<: !include common.yaml