tormatic: cover component for Tormatic and Novoferm garage doors

This commit adds support for Tormatic and Novoferm garage doors
shipped/produced as of 2016.
This commit is contained in:
Timo Beckers 2023-12-14 20:17:35 +01:00
parent 58c0d8c267
commit 6699afa911
No known key found for this signature in database
GPG Key ID: 4130D6AE4B962121
7 changed files with 689 additions and 0 deletions

View File

@ -349,6 +349,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

View File

@ -0,0 +1 @@
CODEOWNERS = ["@ti-mo"]

View 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]))

View 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

View 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

View 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

View File

@ -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