ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
hoermann_hcp.cpp
Go to the documentation of this file.
1#include "hoermann_hcp.h"
2
3#include <algorithm>
4#include <utility>
5
6#include "esphome/core/hal.h"
8#include "esphome/core/log.h"
9
10namespace esphome::hoermann_hcp {
11
12ESPHOME_LOG_TAG(TAG, "hoermann_hcp");
13
14// Hoermann HCP holding-register blocks.
15static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller
16static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller
17static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
18static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
19static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
20
21static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0110};
22static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0120};
23static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0140};
24static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0100, 0x4000};
25static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0100, 0x0400};
26// Absolute, as a vendor gateway sends them, so a late or repeated one cannot switch the lamp the wrong way.
27static constexpr HoermannHcpCommand COMMAND_LIGHT_ON{"light on", 0x0880};
28static constexpr HoermannHcpCommand COMMAND_LIGHT_OFF{"light off", 0x0800, 0x0100};
29// Kept as the intent, as the same impulse starts a door at rest; only a door still moving at the fetch gets it.
30static constexpr HoermannHcpCommand COMMAND_STOP{"stop", 0x0140};
31
32// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
33// its low byte tells a plain stop from the vent position.
34struct DoorStateMapping {
35 uint8_t code;
36 DoorState state;
37};
38static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = {
42};
43
44// The hub rejects a reply whose register count does not match the request, so an unrecognized block length
45// is padded with zeros rather than answered with an exception that would fail the controller's whole poll.
46static void push_zeros(modbus::RegisterValues &registers, uint16_t count) {
47 for (uint16_t i = 0; i < count; i++)
48 registers.push_back(0x0000);
49}
50
51// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving.
52static bool is_moving(DoorState state) {
53 switch (state) {
58 return true;
59 default:
60 return false;
61 }
62}
63
64static bool at_destination(const HoermannHcpCommand &command, DoorState state) {
65 if (&command == &COMMAND_OPEN)
66 return state == DoorState::OPEN;
67 if (&command == &COMMAND_CLOSE)
68 return state == DoorState::CLOSED;
69 if (&command == &COMMAND_VENT)
70 return state == DoorState::VENT;
71 if (&command == &COMMAND_HALF_OPEN)
73 return false;
74}
75
76// Only a status poll's answer carries commands.
77static constexpr uint8_t STATUS_COMMAND = 0x03;
78// A second stop this soon after one went out is ignored, so a double press cannot restart the door.
79static constexpr uint32_t STOP_LOCK_MS = 500;
80
81#ifdef USE_HOERMANN_HCP_IDENTITY
82// A status answer with this code in the low byte of its second register asks the bus controller for a value,
83// named in the high byte of the third.
84static constexpr uint8_t ANSWER_REQUEST = 0x22;
85static constexpr uint8_t REQUEST_SERIAL = 0x05;
86static constexpr uint8_t REQUEST_FIRMWARE = 0x06;
87// Each request goes out in one answer, up to this many times, this far apart.
88static constexpr uint8_t IDENTITY_MAX_ATTEMPTS = 3;
89static constexpr uint32_t IDENTITY_RETRY_MS = 30000;
90// The value comes back as a payload transfer: this command in the low byte of the first command register, a sub
91// code in the high byte of the second, the payload from the third on.
92static constexpr uint8_t TRANSFER_COMMAND = 0x04;
93static constexpr uint8_t TRANSFER_SUB_SERIAL = 0x0C;
94static constexpr uint8_t TRANSFER_SUB_FIRMWARE = 0x0D;
95static constexpr uint8_t TRANSFER_ACK = 0xFD;
96static constexpr size_t TRANSFER_PAYLOAD_REG = 2;
97// Marks the first half of the serial number in the counter byte, and is not part of the count.
98static constexpr uint8_t COUNTER_FIRST_HALF = 0x80;
99// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. It is as long
100// as a second half, so one copy path serves both.
101static constexpr size_t SERIAL_SINGLE_FRAME_REGS = 6;
102static_assert(SERIAL_SINGLE_FRAME_REGS == SERIAL_SECOND_HALF_REGS, "one copy path serves both serial frames");
103
104// Registers hold two payload bytes each, high byte first.
105static void copy_payload(const modbus::RegisterValues &registers, size_t count, char *out) {
106 for (size_t i = 0; i < count; i++) {
107 const uint16_t value = registers[TRANSFER_PAYLOAD_REG + i];
108 out[2 * i] = static_cast<char>(value >> 8);
109 out[2 * i + 1] = static_cast<char>(value);
110 }
111}
112
113// Length of the printable text at the start, without trailing spaces. The padding after it varies.
114static size_t text_length(const char *text, size_t len) {
115 size_t at = 0;
116 while (at < len && text[at] >= 0x20 && text[at] <= 0x7E)
117 at++;
118 while (at > 0 && text[at - 1] == ' ')
119 at--;
120 return at;
121}
122
123static void terminate_text(char *text, size_t len) { text[text_length(text, len)] = '\0'; }
124
125// dump_config() is replayed to remote log clients, so they see the outcome of the exchange at boot.
126static void log_identity_value(text_sensor::TextSensor *sensor) {
127 if (sensor != nullptr) {
128 ESP_LOGCONFIG(TAG, " Value: %s",
129 sensor->has_state() ? sensor->get_state().c_str() : LOG_STR_LITERAL("not received"));
130 }
131}
132#endif
133
134void HoermannHcp::update() {
135 const uint32_t now = millis();
136 // Time out the connection flag if the bus controller stopped polling.
137 if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_)
138 this->set_valid_(false);
139 // Status broadcasts alone keep the connection alive, so a command the controller never fetches would
140 // otherwise block every later one for as long as it keeps broadcasting.
141 // A stop held for a door that has not reported its start yet is timed by the start window instead.
142 const bool stop_held = this->next_command_ == &COMMAND_STOP && this->starting_;
143 if (this->next_command_ != nullptr && !stop_held && now - this->command_queued_at_ > this->connection_timeout_ms_) {
144 ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
145 this->drop_command_();
146 // Children may have assumed the command would land, so let them re-derive from the door.
147 this->changed_ = true;
148 }
149 // A target the door never started towards would otherwise cut a later move short.
150 if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->start_window_ms_) {
151 ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
152 this->clear_target_();
153 }
154 // A door that never answers a fetched command is at rest after all.
155 if (this->starting_ && now - this->start_fetched_at_ > this->start_window_ms_) {
156 this->starting_ = false;
157 if (stop_held) {
158 ESP_LOGW(TAG, "Door did not report moving, dropping the stop");
159 this->next_command_ = nullptr;
160 } else {
161 ESP_LOGD(TAG, "Door did not start after the command");
162 }
163 }
164 // A lamp command held while the door starts or moves waits on purpose, so its deadline starts once the door rests.
165 if (this->is_moving_or_starting_() && this->light_requested_ && !this->light_command_sent_)
166 this->light_since_ = now;
167 // Neither fire late nor block the next request.
168 if (this->light_requested_ && now - this->light_since_ > this->connection_timeout_ms_) {
169 if (this->light_command_sent_) {
170 ESP_LOGW(TAG, "Door did not report the lamp changing, giving up");
171 } else {
172 ESP_LOGW(TAG, "Bus controller did not fetch the lamp command, dropping it");
173 }
174 this->clear_light_request_();
175 }
176#ifdef USE_HOERMANN_HCP_IDENTITY
177 this->publish_identity_();
178#endif
179 if (this->changed_) {
180 this->changed_ = false;
181 this->state_callback_.call();
182 }
183}
184
185void HoermannHcp::dump_config() {
186 ESP_LOGCONFIG(TAG,
187 "Hoermann HCP bridge:\n"
188 " Modbus server address: 0x%02X",
189 this->get_address());
190#ifdef USE_HOERMANN_HCP_IDENTITY
191 LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_);
192 log_identity_value(this->serial_number_text_sensor_);
193 LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_);
194 log_identity_value(this->version_text_sensor_);
195#endif
196}
197
198modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
199 modbus::RegisterValues &registers) {
200 if (start_address != STATE_REG) {
201 ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address);
203 }
204
205 this->record_response_();
206 const bool status_poll = std::exchange(this->status_poll_pending_, false);
207
208#ifdef USE_HOERMANN_HCP_IDENTITY
209 // Acknowledge the transfer taken by the write half of this frame.
210 if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) {
211 this->push_transfer_answer_(registers, number_of_registers);
212 return {};
213 }
214#endif
215
216 // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message
217 // counter (high byte) and command (low byte). The read length identifies which internal block is requested.
218 const uint16_t counter = this->command_reg_value_ & 0xFF00;
219 const uint16_t command = static_cast<uint16_t>((this->command_reg_value_ & 0x00FF) << 8);
220
221 switch (number_of_registers) {
222 case 8:
223 // Command request: return the internal state, injecting any pending command.
224 registers.push_back(counter);
225 registers.push_back(static_cast<uint16_t>(0x0001 | command));
226 if (status_poll && static_cast<uint8_t>(this->command_reg_value_) == STATUS_COMMAND) {
227 this->push_command_registers_(registers);
228 } else {
229 push_zeros(registers, 2);
230 }
231 push_zeros(registers, 4);
232#ifdef USE_HOERMANN_HCP_IDENTITY
233 this->add_identity_request_(registers, command);
234#endif
235 break;
236 case 2:
237 // Empty command request.
238 registers.push_back(static_cast<uint16_t>(0x0004 | counter));
239 registers.push_back(command);
240 break;
241 case 5:
242 // Bus scan (the bus controller discovering us, typically at startup).
243 ESP_LOGD(TAG, "Bus scan received from bus controller");
244 registers.push_back(counter);
245 registers.push_back(static_cast<uint16_t>(0x0005 | command));
246 registers.push_back(0x0430);
247 registers.push_back(0x10FF);
248 registers.push_back(0xA845);
249 break;
250 default:
251 ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers);
252 push_zeros(registers, number_of_registers);
253 break;
254 }
255
256 return {};
257}
258
259modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
260 const modbus::RegisterValues &registers) {
261 if (start_address == COMMAND_REG) {
262 // 0x17 write half: stash the command register so the following read half can echo its message counter and
263 // command byte back from STATE_REG. The hub always runs the write before the read within one request.
264 this->record_response_();
265 this->command_reg_value_ = registers[0];
266 this->status_poll_pending_ = true;
267#ifdef USE_HOERMANN_HCP_IDENTITY
268 this->transfer_answer_counter_ = this->take_identity_transfer_(registers);
269#endif
270 return {};
271 }
272
273 if (start_address != BROADCAST_REG) {
274 // Every device sees every broadcast, so a frame meant for another node is ordinary traffic
275 ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address);
277 }
278
279 this->record_response_();
280
281 // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new
282 // position checks the target against the new state.
283 if (registers.size() > 2)
284 this->on_state_reg_(registers[2]);
285 if (registers.size() > 1)
286 this->on_position_reg_(registers[1]);
287 if (registers.size() > 6) {
288 this->on_light_reg_(registers[6]);
289 return {};
290 }
291 // Nothing refreshes the lamp any more, so what was read before must not be commanded against.
292 this->set_light_seen_(false);
293 if (!this->short_broadcast_logged_) {
294 this->short_broadcast_logged_ = true;
295 ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast<unsigned>(registers.size()));
296 }
297 return {};
298}
299
301 const HoermannHcpCommand *command = this->take_command_();
302 if (command == nullptr)
303 command = this->take_light_command_();
304 if (command == nullptr) {
305 push_zeros(registers, 2);
306 return;
307 }
308 ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
309 registers.push_back(command->value);
310 registers.push_back(command->value_2);
311}
312
314 const HoermannHcpCommand *command = this->next_command_;
315 if (command == nullptr)
316 return nullptr;
317 const bool moving = is_moving(this->door_state_);
318 // The door was just told to start and has not said so yet, so an impulse now could start it instead.
319 if (command == &COMMAND_STOP && this->starting_ && !moving)
320 return nullptr;
321 this->next_command_ = nullptr;
322 if (moving) {
323 // The door may have been started from elsewhere since the command was queued.
324 if ((command == &COMMAND_OPEN && this->door_state_ == DoorState::OPENING) ||
325 (command == &COMMAND_CLOSE && this->door_state_ == DoorState::CLOSING)) {
326 ESP_LOGD(TAG, "Door is already moving that way, dropping '%s'", command->name);
327 return nullptr;
328 }
329 if (command != &COMMAND_STOP) {
330 ESP_LOGD(TAG, "Door is moving, stopping it instead of '%s'", command->name);
331 }
332 this->last_stop_at_ = millis();
333 this->stop_sent_ = true;
334 return &COMMAND_STOP;
335 }
336 if (command == &COMMAND_STOP) {
337 ESP_LOGD(TAG, "Door came to rest before the stop was fetched, dropping it");
338 return nullptr;
339 }
340 // Until the door answers, it still reads as at rest.
341 if (!this->door_state_seen_ || !at_destination(*command, this->door_state_)) {
342 this->starting_ = true;
343 this->start_command_ = command;
344 this->start_fetched_at_ = millis();
345 }
346 return command;
347}
348
350 // The motor ignores the lamp while its door moves and may switch it itself as it starts, so the lamp waits for rest.
351 if (!this->light_requested_ || this->light_command_sent_ || this->is_moving_or_starting_() ||
352 this->light_target_ == this->light_on_)
353 return nullptr;
354 this->light_command_sent_ = true;
355 this->light_since_ = millis();
356 return this->light_target_ ? &COMMAND_LIGHT_ON : &COMMAND_LIGHT_OFF;
357}
358
359#ifdef USE_HOERMANN_HCP_IDENTITY
361 if (static_cast<uint8_t>(this->command_reg_value_) != STATUS_COMMAND)
362 return;
363 // Like Hoermann's own bus accessory, only after one ordinary answer.
365 if (this->serial_number_text_sensor_ != nullptr || this->version_text_sensor_ != nullptr)
367 return;
368 }
369 // Uses the command registers, so it waits while a command is pending.
370 if (registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_())
371 return;
372 registers[1] = static_cast<uint16_t>(ANSWER_REQUEST | command);
373 registers[2] = encode_uint16(this->identity_request_(), 0);
374}
375
377 this->identity_phase_ = phase;
378 this->identity_attempts_ = 0;
379}
380
382 const uint8_t request = this->identity_request_();
383 if (request == 0)
384 return false;
385 const uint32_t now = millis();
386 if (this->identity_attempts_ != 0 && now - this->identity_asked_at_ <= IDENTITY_RETRY_MS)
387 return false;
388 if (this->identity_attempts_ >= IDENTITY_MAX_ATTEMPTS) {
389 this->identity_unanswered_ = request;
390 if (request == REQUEST_SERIAL) {
391 // Still ask for the firmware version, and drop a leftover first half.
392 this->serial_number_[0] = '\0';
394 } else {
396 }
397 return false;
398 }
399 this->identity_attempts_++;
400 this->identity_asked_at_ = now;
401 return true;
402}
403
405 if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE || registers.size() < TRANSFER_PAYLOAD_REG ||
406 static_cast<uint8_t>(registers[0]) != TRANSFER_COMMAND)
407 return NO_TRANSFER_ANSWER;
408 const uint8_t counter = static_cast<uint8_t>(registers[0] >> 8);
409 const uint8_t sub_code = static_cast<uint8_t>(registers[1] >> 8);
410 if (sub_code != TRANSFER_SUB_SERIAL && sub_code != TRANSFER_SUB_FIRMWARE)
411 return NO_TRANSFER_ANSWER;
412 // Acknowledged whether kept or not, as Hoermann's own bus accessory does. What was not kept is asked for again.
413 const uint8_t answer = counter & ~COUNTER_FIRST_HALF;
414 // Only an answer to the request that went out is kept.
415 const uint8_t request = sub_code == TRANSFER_SUB_SERIAL ? REQUEST_SERIAL : REQUEST_FIRMWARE;
416 if (this->identity_attempts_ == 0 || this->identity_request_() != request)
417 return answer;
418
419 const size_t payload_regs = registers.size() - TRANSFER_PAYLOAD_REG;
420 if (request == REQUEST_FIRMWARE) {
421 const size_t regs = std::min(payload_regs, FIRMWARE_REGS);
422 copy_payload(registers, regs, this->firmware_version_);
423 if (regs == FIRMWARE_REGS && text_length(this->firmware_version_, 2 * regs) != 0) {
424 terminate_text(this->firmware_version_, 2 * regs);
425 // Clear an earlier unreadable answer, so this one is shown.
426 this->firmware_unreadable_ = false;
428 return answer;
429 }
430 // Left as received for update() to log.
431 this->firmware_unreadable_ = true;
432 this->firmware_unreadable_len_ = 2 * regs;
433 // A short answer is asked for again; unreadable text would come back the same.
434 if (regs == FIRMWARE_REGS)
436 return answer;
437 }
438 if ((counter & COUNTER_FIRST_HALF) != 0) {
439 // Even a half too short to keep says the number comes in two halves; a first half already kept stays kept.
442 if (payload_regs >= SERIAL_FIRST_HALF_REGS) {
443 copy_payload(registers, SERIAL_FIRST_HALF_REGS, this->serial_number_);
445 }
446 return answer;
447 }
448 // Without a half marker seen, the frame is the whole number; after one, it can only be the second half.
449 if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT || payload_regs < SERIAL_SINGLE_FRAME_REGS)
450 return answer;
451 const size_t at =
452 this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF ? 2 * SERIAL_FIRST_HALF_REGS : 0;
453 copy_payload(registers, SERIAL_SINGLE_FRAME_REGS, this->serial_number_ + at);
454 terminate_text(this->serial_number_, at + 2 * SERIAL_SINGLE_FRAME_REGS);
455 this->serial_unreadable_ = this->serial_number_[0] == '\0';
457 return answer;
458}
459
460void HoermannHcp::push_transfer_answer_(modbus::RegisterValues &registers, uint16_t number_of_registers) {
461 const uint16_t answer[] = {encode_uint16(this->transfer_answer_counter_, 0),
462 encode_uint16(TRANSFER_COMMAND, TRANSFER_ACK)};
463 this->transfer_answer_counter_ = NO_TRANSFER_ANSWER;
464 for (uint16_t i = 0; i < number_of_registers; i++)
465 registers.push_back(i < 2 ? answer[i] : 0x0000);
466}
467
469 // Buffers are cleared once published, so each value goes out once. The serial number is whole once the firmware
470 // version is requested.
471 if (this->serial_number_text_sensor_ != nullptr && this->identity_request_() != REQUEST_SERIAL &&
472 this->serial_number_[0] != '\0') {
473 this->serial_number_text_sensor_->publish_state(this->serial_number_);
474 this->serial_number_[0] = '\0';
475 }
476 if (this->serial_unreadable_) {
477 this->serial_unreadable_ = false;
478 ESP_LOGW(TAG, "Unreadable serial number");
479 }
480 // Checked first: the buffer then holds raw bytes, not text.
481 if (this->firmware_unreadable_) {
482 this->firmware_unreadable_ = false;
483 const uint8_t len = this->firmware_unreadable_len_;
484 // All zeros: the motor does not report a version (index B1 seen).
485 if (len == 2 * FIRMWARE_REGS &&
486 std::all_of(this->firmware_version_, this->firmware_version_ + len, [](char c) { return c == '\0'; })) {
487 ESP_LOGD(TAG, "Motor does not report its firmware version");
488 } else {
489 char hex[format_hex_size(2 * FIRMWARE_REGS)];
490 ESP_LOGW(TAG, "Unreadable firmware version (%u bytes): %s", len,
491 format_hex_to(hex, reinterpret_cast<const uint8_t *>(this->firmware_version_), len));
492 }
493 this->firmware_version_[0] = '\0';
494 }
495 if (this->version_text_sensor_ != nullptr && this->firmware_version_[0] != '\0') {
496 this->version_text_sensor_->publish_state(this->firmware_version_);
497 this->firmware_version_[0] = '\0';
498 }
499 if (this->identity_unanswered_ != 0) {
500 ESP_LOGW(TAG, "No usable %s received",
501 this->identity_unanswered_ == REQUEST_SERIAL ? LOG_STR_LITERAL("serial number")
502 : LOG_STR_LITERAL("firmware version"));
503 this->identity_unanswered_ = 0;
504 }
505}
506#endif
507
508void HoermannHcp::on_position_reg_(uint16_t value) {
509 // Low byte: current position.
510 const uint8_t position = static_cast<uint8_t>(value);
511 if (this->position_raw_ == position)
512 return;
513
514 this->position_raw_ = position;
516 // Until the door actually travels the way it was told to, its position says nothing about the target.
517 if (!this->has_target_() || !this->target_started_)
518 return;
519
520 // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way.
521 const bool reached = this->target_direction_ == DoorState::OPENING
522 ? this->current_position_ >= this->target_position_
523 : this->current_position_ <= this->target_position_;
524 if (reached)
525 this->stop_door();
526}
527
528void HoermannHcp::on_state_reg_(uint16_t value) {
529 // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte.
530 const uint16_t previous = this->prev_state_reg_;
531 this->prev_state_reg_ = value;
532 if (previous == value)
533 return;
534
535 const uint8_t state = value >> 8;
536 if (state == 0x00) {
537 // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop.
538 this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED);
539 return;
540 }
541 for (const auto &mapping : DOOR_STATE_MAPPINGS) {
542 if (mapping.code == state) {
543 this->set_door_state_(mapping.state);
544 return;
545 }
546 }
547 // The low byte can change on its own, so only report a state we cannot decode once.
548 if (state != (previous >> 8)) {
549 ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
550 }
551}
552
553// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
554// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
555void HoermannHcp::on_light_reg_(uint16_t value) {
556 this->set_light_seen_(true);
557 this->set_light_on_((value & 0x0010) != 0);
558}
559
561 if (!this->valid_) {
562 // Queueing now would fire the command whenever the controller comes back, which may be much later.
563 ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
564 return false;
565 }
566 // A stop still waiting for a door that has come to rest would be dropped at the fetch anyway.
567 if (this->next_command_ == &COMMAND_STOP)
568 this->next_command_ = nullptr;
569 if (this->next_command_ != nullptr) {
570 ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
571 return false;
572 }
573 // A new command supersedes any half-open target the door was still travelling to.
574 this->clear_target_();
575 this->next_command_ = &command;
576 this->command_queued_at_ = millis();
577 return true;
578}
579
580bool HoermannHcp::is_moving_or_starting_() const { return this->starting_ || is_moving(this->door_state_); }
581
583 // Only stopped, so it is never reversed at speed. take_command_() drops one queued before the door started the same
584 // way.
585 if (this->is_moving_or_starting_())
586 return this->stop_door();
587 return this->queue_command_(command);
588}
589
590bool HoermannHcp::open_door() { return this->command_door_(COMMAND_OPEN); }
591bool HoermannHcp::close_door() { return this->command_door_(COMMAND_CLOSE); }
592bool HoermannHcp::impulse_door() { return this->command_door_(COMMAND_IMPULSE); }
593bool HoermannHcp::vent_door() { return this->command_door_(COMMAND_VENT); }
594bool HoermannHcp::half_open_door() { return this->command_door_(COMMAND_HALF_OPEN); }
595bool HoermannHcp::stop_door() {
596 this->clear_target_();
597 // A stop outranks whatever is still waiting, so a door at rest does not start after the user pressed stop.
598 if (this->next_command_ != nullptr && this->next_command_ != &COMMAND_STOP) {
599 ESP_LOGD(TAG, "Stop cancels the unfetched '%s' command", this->next_command_->name);
600 this->next_command_ = nullptr;
601 }
602 if (!this->is_moving_or_starting_())
603 return true;
604 if (!this->valid_) {
605 ESP_LOGW(TAG, "Not connected to the bus controller, dropping 'stop' command");
606 return false;
607 }
608 if (this->next_command_ == &COMMAND_STOP)
609 return true;
610 if (this->stop_sent_ && millis() - this->last_stop_at_ < STOP_LOCK_MS) {
611 ESP_LOGD(TAG, "Door already stopping, ignoring stop");
612 return true;
613 }
614 this->next_command_ = &COMMAND_STOP;
615 this->command_queued_at_ = millis();
616 return true;
617}
618
619bool HoermannHcp::set_position(float position) {
620 // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed.
621 if (position <= CLOSE_POSITION_THRESHOLD)
622 return this->close_door();
623 if (position >= OPEN_POSITION_THRESHOLD)
624 return this->open_door();
625 // Asking the door to travel to where it already is, or anywhere while it moves, means stopping it.
626 if (position == this->current_position_ || this->is_moving_or_starting_())
627 return this->stop_door();
628
629 // The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
630 const bool opening = position > this->current_position_;
631 if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
632 return false;
634 this->target_queued_at_ = millis();
636 return true;
637}
638
640 this->last_response_ = millis();
641 this->set_valid_(true);
642}
643
645 if (this->valid_ == valid)
646 return;
647 this->valid_ = valid;
648 this->changed_ = true;
649 if (valid) {
650 ESP_LOGI(TAG, "Bus controller connected");
651 return;
652 }
653 ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
654 // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
655 this->drop_command_();
656 this->starting_ = false;
657 this->clear_light_request_();
658 // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
659 this->set_light_seen_(false);
660 // The same holds for the door. The next broadcast is decoded even if it repeats the last one.
661 this->door_state_seen_ = false;
662 this->prev_state_reg_ = 0xFFFF;
663 this->short_broadcast_logged_ = false;
664}
665
667 this->next_command_ = nullptr;
668 this->clear_target_();
669}
670
672 // The first state may equal the default, so being seen is a change of its own.
673 if (!this->door_state_seen_) {
674 this->door_state_seen_ = true;
675 this->changed_ = true;
676 }
677 // Only moving or the command's destination answers it, even as a first report that changes nothing.
678 if (this->starting_ && (is_moving(state) || at_destination(*this->start_command_, state))) {
679 // A stop held for the start is due now, so its fetch deadline starts here.
680 if (this->next_command_ == &COMMAND_STOP)
681 this->command_queued_at_ = millis();
682 this->starting_ = false;
683 }
684 if (this->door_state_ == state)
685 return;
686 this->door_state_ = state;
687 this->changed_ = true;
688 if (!is_moving(state)) {
689 // A door at rest cannot be restarted by a second stop, as stop_door() sends nothing then.
690 this->stop_sent_ = false;
691 }
693 if (!this->has_target_())
694 return;
695 if (state == this->target_direction_) {
696 this->target_started_ = true;
697 } else if (this->target_started_ && !is_moving(state)) {
698 // The door came to rest without reaching the target, so the request it belonged to is over.
699 this->clear_target_();
700 }
701}
702
704 // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so
705 // trust the reported end stop over the raw count.
706 float position = static_cast<float>(this->position_raw_) / 200.0f;
707 if (this->door_state_ == DoorState::CLOSED) {
708 position = 0.0f;
709 } else if (this->door_state_ == DoorState::OPEN) {
710 position = 1.0f;
711 }
712 if (this->current_position_ != position) {
713 this->current_position_ = position;
714 this->changed_ = true;
715 }
716}
717
719 this->target_position_ = 0.0f;
720 this->target_started_ = false;
721}
722
724 if (this->light_on_ == on)
725 return;
726 this->light_on_ = on;
727 this->changed_ = true;
728 if (!this->light_requested_) {
729 ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
730 return;
731 }
732 if (on == this->light_target_) {
733 this->clear_light_request_();
734 return;
735 }
736 // Switched away from the target while the command was out: send again.
737 this->light_command_sent_ = false;
738 this->light_since_ = millis();
739}
740
741bool HoermannHcp::set_light(bool on) {
742 // A known lamp implies a live connection.
743 if (!this->light_seen_)
744 return false;
745 this->light_target_ = on;
746 const bool was_requested = this->light_requested_;
747 // A sent command may still switch it away.
748 this->light_requested_ = on != this->light_on_ || this->light_command_sent_;
749 // The deadline belongs to the request, so more taps cannot keep it alive.
750 if (!was_requested)
751 this->light_since_ = millis();
752 this->changed_ = true;
753 return true;
754}
755
757 if (!this->light_requested_)
758 return;
759 this->light_requested_ = false;
760 this->light_command_sent_ = false;
761 this->changed_ = true;
762}
763
765 if (this->light_seen_ == seen)
766 return;
767 this->light_seen_ = seen;
768 if (!seen)
769 this->clear_light_request_();
770 // A resting door changes nothing else, so without this the light would never hear about it.
771 this->changed_ = true;
772}
773
774} // namespace esphome::hoermann_hcp
virtual void update()=0
Minimal static vector - saves memory by avoiding std::vector overhead.
Definition helpers.h:286
size_t size() const
Definition helpers.h:355
void push_back(const T &value)
Definition helpers.h:325
bool command_door_(const HoermannHcpCommand &command)
void set_door_state_(DoorState state)
uint8_t take_identity_transfer_(const modbus::RegisterValues &registers)
CallbackManager< void()> state_callback_
const HoermannHcpCommand * take_command_()
void arm_identity_request_(IdentityPhase phase)
const HoermannHcpCommand * next_command_
void push_command_registers_(modbus::RegisterValues &registers)
void add_identity_request_(modbus::RegisterValues &registers, uint16_t command)
const HoermannHcpCommand * start_command_
char firmware_version_[2 *FIRMWARE_REGS+1]
void push_transfer_answer_(modbus::RegisterValues &registers, uint16_t number_of_registers)
const HoermannHcpCommand * take_light_command_()
bool queue_command_(const HoermannHcpCommand &command)
void on_position_reg_(uint16_t value)
char serial_number_[2 *(SERIAL_FIRST_HALF_REGS+SERIAL_SECOND_HALF_REGS)+1]
float position
Definition cover.h:0
bool state
Definition fan.h:2
std::vector< uint8_t > & out
Definition gif.cpp:49
unsigned count
Definition gif.cpp:51
ESPHOME_LOG_TAG(TAG, "hoermann_hcp.binary_sensor")
StaticVector< uint16_t, MAX_NUM_OF_REGISTERS_TO_READ > RegisterValues
Definition modbus.h:315
std::optional< ExceptionCode > ResponseStatus
Definition modbus.h:307
const void size_t len
Definition hal.h:68
constexpr size_t format_hex_size(size_t byte_count)
Calculate buffer size needed for format_hex_to: "XXXXXXXX...\0" = bytes * 2 + 1.
Definition helpers.h:1507
constexpr uint16_t encode_uint16(uint8_t msb, uint8_t lsb)
Encode a 16-bit value given the most and least significant byte.
Definition helpers.h:980
uint32_t IRAM_ATTR HOT millis()
Definition hal.cpp:25
char * format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length)
Format byte array as lowercase hex to buffer (base implementation).
Definition helpers.cpp:428
bool valid
static void uint32_t