ESPHome 2026.10.0-dev
Loading...
Searching...
No Matches
tormatic_cover.cpp
Go to the documentation of this file.
1#include <cinttypes>
2#include <vector>
3
4#include "tormatic_cover.h"
5
6using namespace std;
7
9
10static const char *const TAG = "tormatic.cover";
11
12// Time to poll the UART when flushing after desync. At 9600 baud, a full
13// 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived.
14static constexpr uint32_t DRAIN_TIMEOUT_MS = 15;
15
16using namespace esphome::cover;
17
19 auto restore = this->restore_state_();
20 if (restore.has_value()) {
21 restore->apply(this);
22 return;
23 }
24
25 // Assume gate is closed without preexisting state.
26 this->position = 0.0f;
27}
28
30 auto traits = CoverTraits();
31 traits.set_supports_stop(true);
32 traits.set_supports_position(true);
33 traits.set_is_assumed_state(false);
34 return traits;
35}
36
38 LOG_COVER("", "Tormatic Cover", this);
39 ESP_LOGCONFIG(TAG,
40 " Open Duration: %.1fs\n"
41 " Close Duration: %.1fs",
42 this->open_duration_ / 1e3f, this->close_duration_ / 1e3f);
43
44 auto restore = this->restore_state_();
45 if (restore.has_value()) {
46 ESP_LOGCONFIG(TAG, " Saved position %d%%", (int) (restore->position * 100.f));
47 }
48}
49
51
53 auto o_status = this->read_gate_status_();
54 if (o_status) {
55 auto status = o_status.value();
56
59 }
60
61 this->recompute_position_();
62 this->stop_at_target_();
63}
64
66 if (call.get_stop()) {
68 return;
69 }
70
71 auto pos_val = call.get_position();
72 if (pos_val.has_value()) {
73 auto pos = *pos_val;
75 return;
76 }
77}
78
79// Wrap the Cover's publish_state with a rate limiter. Publishes if the last
80// publish was longer than ratelimit milliseconds ago. 0 to disable.
81void Tormatic::publish_state(bool save, uint32_t ratelimit) {
82 auto now = millis();
83 if ((now - this->last_publish_time_) < ratelimit) {
84 return;
85 }
86 this->last_publish_time_ = now;
87
89};
90
91// Recalibrate the gate's estimated open or close duration based on the
92// actual time the operation took.
94 if (this->current_status_ == s) {
95 return;
96 }
97
98 auto now = millis();
99 auto old = this->current_status_;
100
101 // Gate paused halfway through opening or closing, invalidate the start time
102 // of the current operation. Close/open durations can only be accurately
103 // calibrated on full open or close cycle due to motor acceleration.
104 if (s == PAUSED) {
105 ESP_LOGD(TAG, "Gate paused, clearing direction start time");
106 this->direction_start_time_ = 0;
107 return;
108 }
109
110 // Record the start time of a state transition if the gate was in the fully
111 // open or closed position before the command.
112 if ((old == CLOSED && s == OPENING) || (old == OPENED && s == CLOSING)) {
113 ESP_LOGD(TAG, "Gate started moving from fully open or closed state");
114 this->direction_start_time_ = now;
115 return;
116 }
117
118 // The gate was resumed from a paused state, don't attempt recalibration.
119 if (this->direction_start_time_ == 0) {
120 return;
121 }
122
123 if (s == OPENED) {
124 this->open_duration_ = now - this->direction_start_time_;
125 ESP_LOGI(TAG, "Recalibrated the gate's open duration to %" PRIu32 "ms", this->open_duration_);
126 }
127 if (s == CLOSED) {
128 this->close_duration_ = now - this->direction_start_time_;
129 ESP_LOGI(TAG, "Recalibrated the gate's close duration to %" PRIu32 "ms", this->close_duration_);
130 }
131
132 this->direction_start_time_ = 0;
133}
134
135// Set the Cover's internal state based on a status message
136// received from the unit.
138 if (this->current_status_ == s) {
139 return;
140 }
141
142 ESP_LOGI(TAG, "Status changed from %s to %s", gate_status_to_str(this->current_status_), gate_status_to_str(s));
143
144 switch (s) {
145 case OPENED:
146 // The Novoferm 423 doesn't respond to the first 'Close' command after
147 // being opened completely. Sending a pause command after opening fixes
148 // that.
150
151 this->position = COVER_OPEN;
152 break;
153 case CLOSED:
154 this->position = COVER_CLOSED;
155 break;
156 default:
157 break;
158 }
159
160 this->current_status_ = s;
162
163 this->publish_state(true);
164
165 // This timestamp is used to generate position deltas on every loop() while
166 // the gate is moving. Bump it on each state transition so the first tick
167 // doesn't generate a huge delta.
169}
170
171// Recompute the gate's position and publish the results while
172// the gate is moving. No-op when the gate is idle.
175 return;
176 }
177
178 const uint32_t now = millis();
179 uint32_t diff = now - this->last_recompute_time_;
180
181 auto direction = +1.0f;
184 direction = -1.0f;
185 duration = this->close_duration_;
186 }
187
188 if (duration == 0)
189 return;
190
191 auto delta = direction * diff / duration;
192
193 this->position = clamp(this->position + delta, COVER_CLOSED, COVER_OPEN);
194
195 this->last_recompute_time_ = now;
196
197 this->publish_state(true, 250);
198}
199
200// Start moving the gate in the direction of the target position.
201void Tormatic::control_position_(float target) {
202 if (target == this->position) {
203 return;
204 }
205
206 if (target == COVER_OPEN) {
207 ESP_LOGI(TAG, "Fully opening gate");
209 return;
210 }
211 if (target == COVER_CLOSED) {
212 ESP_LOGI(TAG, "Fully closing gate");
214 return;
215 }
216
217 // Don't set target position when fully opening or closing the gate, the gate
218 // stops automatically when it reaches the configured open/closed positions.
219 this->target_position_ = target;
220
221 if (target > this->position) {
222 ESP_LOGI(TAG, "Opening gate towards %.1f", target);
224 return;
225 }
226
227 if (target < this->position) {
228 ESP_LOGI(TAG, "Closing gate towards %.1f", target);
230 return;
231 }
232}
233
234// Stop the gate if it is moving at or beyond its target position. Target
235// position is only set when the gate is requested to move to a halfway
236// position.
239 return;
240 }
241 if (!this->target_position_) {
242 return;
243 }
244 auto target = this->target_position_.value();
245
246 if (this->current_operation == COVER_OPERATION_OPENING && this->position < target) {
247 return;
248 }
249 if (this->current_operation == COVER_OPERATION_CLOSING && this->position > target) {
250 return;
251 }
252
254 this->target_position_.reset();
255}
256
257// Read a GateStatus from the unit. The unit only sends messages in response to
258// status requests or commands, so a message needs to be sent first.
259optional<GateStatus> Tormatic::read_gate_status_() {
260 if (!this->pending_hdr_) {
261 if (this->available() < sizeof(MessageHeader)) {
262 return {};
263 }
264
266 if (!this->pending_hdr_) {
267 return {};
268 }
269
270 // Sanity check: valid messages have small payloads (3-4 bytes). A large
271 // or impossible payload_size means the stream is out of sync (corrupted
272 // byte, dropped data, etc.). Flush the buffer so we can resync on the
273 // next request/response cycle.
274 if (this->pending_hdr_->payload_size() > sizeof(CommandRequestReply)) {
275 ESP_LOGW(TAG, "Unexpected payload size %" PRIu32 ", flushing rx buffer", this->pending_hdr_->payload_size());
276 this->pending_hdr_.reset();
277 this->drain_rx_();
278 return {};
279 }
280 }
281
282 auto hdr = this->pending_hdr_.value();
283
284 // Wait for all payload bytes to arrive before processing.
285 if (this->available() < hdr.payload_size()) {
286 return {};
287 }
288
289 this->pending_hdr_.reset();
290
291 switch (hdr.type) {
292 case STATUS: {
293 if (hdr.payload_size() != sizeof(StatusReply)) {
294 ESP_LOGE(TAG, "Header specifies payload size %" PRIu32 " but size of StatusReply is %zu", hdr.payload_size(),
295 sizeof(StatusReply));
296 this->drain_rx_(hdr.payload_size());
297 return {};
298 }
299
300 auto o_status = this->read_data_<StatusReply>();
301 if (!o_status) {
302 return {};
303 }
304
305 return o_status->state;
306 }
307
308 case COMMAND:
309 // Commands initiated by control() are simply echoed back by the unit, but
310 // don't guarantee that the unit's internal state has been transitioned,
311 // nor that the motor started moving. A subsequent status request may
312 // still return the previous state. Discard these messages, don't use them
313 // to drive the Cover state machine.
314 break;
315
316 default:
317 // Unknown message type, drain the remaining amount of bytes specified in
318 // the header.
319 ESP_LOGE(TAG, "Reading remaining %" PRIu32 " payload bytes of unknown type 0x%x", hdr.payload_size(), hdr.type);
320 break;
321 }
322
323 // Drain any unhandled payload bytes described by the message header, if any.
324 this->drain_rx_(hdr.payload_size());
325
326 return {};
327}
328
329// Send a message to the unit requesting the gate's status.
331 ESP_LOGV(TAG, "Requesting gate status");
332 StatusRequest req(GATE);
333 this->send_message_(STATUS, req);
334}
335
336// Send a message to the unit issuing a command.
338 ESP_LOGI(TAG, "Sending gate command %s", gate_status_to_str(s));
339 CommandRequestReply req(s);
340 this->send_message_(COMMAND, req);
341}
342
343template<typename T> void Tormatic::send_message_(MessageType t, T req) {
344 MessageHeader hdr(t, ++this->seq_tx_, sizeof(req));
345
346 auto out = serialize(hdr);
347 auto reqv = serialize(req);
348 out.insert(out.end(), reqv.begin(), reqv.end());
349
350 this->write_array(out);
351}
352
353template<typename T> optional<T> Tormatic::read_data_() {
354 T obj;
355 uint32_t start = millis();
356
357 auto ok = this->read_array((uint8_t *) &obj, sizeof(obj));
358 if (!ok) {
359 // Couldn't read object successfully, timeout?
360 return {};
361 }
362 obj.byteswap();
363
364 ESP_LOGV(TAG, "Read %s in %" PRIu32 " ms", obj.print().c_str(), millis() - start);
365 return obj;
366}
367
368// Drain bytes from the uart rx buffer. When n > 0, drain exactly n bytes
369// (caller must ensure they are available). When n == 0, poll for 15ms to
370// guarantee a full packet time at 9600 baud has elapsed, consuming any
371// bytes still in transit.
372void Tormatic::drain_rx_(uint16_t n) {
373 uint8_t data;
374 if (n > 0) {
375 for (uint16_t i = 0; i < n; i++) {
376 if (!this->read_byte(&data)) {
377 return;
378 }
379 }
380 } else {
381 uint32_t start = millis();
382 while (millis() - start < DRAIN_TIMEOUT_MS) {
383 if (this->available()) {
384 this->read_byte(&data);
385 }
386 }
387 }
388}
389
390} // namespace esphome::tormatic
uint8_t status
Definition bl0942.h:8
CoverOperation current_operation
The current operation of the cover (idle, opening, closing).
Definition cover.h:115
optional< CoverRestoreState > restore_state_()
Definition cover.cpp:175
void publish_state(bool save=true)
Publish the current state of the cover.
Definition cover.cpp:138
float position
The position of the cover from 0.0 (fully closed) to 1.0 (fully open).
Definition cover.h:121
optional< GateStatus > read_gate_status_()
void send_message_(MessageType t, T r)
void recalibrate_duration_(GateStatus s)
optional< MessageHeader > pending_hdr_
optional< float > target_position_
void send_gate_command_(GateStatus s)
void control(const cover::CoverCall &call) override
void control_position_(float target)
void handle_gate_status_(GateStatus s)
cover::CoverTraits get_traits() override
void publish_state(bool save=true, uint32_t ratelimit=0)
optional< std::array< uint8_t, N > > read_array()
Definition uart.h:39
bool read_byte(uint8_t *data)
Definition uart.h:35
void write_array(const uint8_t *data, size_t len)
Definition uart.h:27
FanDirection direction
Definition fan.h:5
uint8_t duration
Definition msa3xx.h:0
@ COVER_OPERATION_OPENING
The cover is currently opening.
Definition cover.h:83
@ COVER_OPERATION_CLOSING
The cover is currently closing.
Definition cover.h:85
@ COVER_OPERATION_IDLE
The cover is currently idle (not moving)
Definition cover.h:81
This file implements the UART protocol spoken over the on-board Micro-USB (Type B) connector of Torma...
CoverOperation gate_status_to_cover_operation(GateStatus s)
std::vector< uint8_t > serialize(T obj)
const char * gate_status_to_str(GateStatus s)
size_t size_t pos
Definition helpers.h:1092
uint32_t IRAM_ATTR HOT millis()
Definition hal.cpp:28
STL namespace.
static void uint32_t