ESPHome 2026.8.0b4
Loading...
Searching...
No Matches
hoermann_hcp.cpp
Go to the documentation of this file.
1#include "hoermann_hcp.h"
2
3#include "esphome/core/hal.h"
4#include "esphome/core/log.h"
5
6namespace esphome::hoermann_hcp {
7
8static const char *const TAG = "hoermann_hcp";
9
10// Hoermann HCP holding-register blocks.
11static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller
12static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller
13static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
14static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
15static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
16// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away.
17static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
18
19// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the
20// rest names the button - the low byte for the door commands, the second register for those that do not fit
21// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each.
22static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
23static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
24static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
25// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
26static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
27
28// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
29// its low byte tells a plain stop from the vent position.
30struct DoorStateMapping {
31 uint8_t code;
32 DoorState state;
33};
34static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = {
38};
39
40// The hub rejects a reply whose register count does not match the request, so an unrecognized block length
41// is padded with zeros rather than answered with an exception that would fail the controller's whole poll.
42static void push_zeros(modbus::RegisterValues &registers, uint16_t count) {
43 for (uint16_t i = 0; i < count; i++)
44 registers.push_back(0x0000);
45}
46
47// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving.
48static bool is_moving(DoorState state) {
49 switch (state) {
54 return true;
55 default:
56 return false;
57 }
58}
59
61 const uint32_t now = millis();
62 // Time out the connection flag if the bus controller stopped polling.
63 if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_)
64 this->set_valid_(false);
65 // Status broadcasts alone keep the connection alive, so a command the controller never fetches would
66 // otherwise block every later one for as long as it keeps broadcasting.
67 if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
68 // Dropping after the press was presented leaves the door without its release value, which is worth saying
69 // apart from a command the controller never looked at.
70 if (this->command_written_at_ != 0) {
71 ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
72 this->next_command_->name);
73 } else {
74 ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
75 }
76 this->drop_command_();
77 // Children may have assumed the command would land, so let them re-derive from the door.
78 this->changed_ = true;
79 }
80 // A target waits for a door still travelling the other way to turn around. If it never does, the target has
81 // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
82 if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
83 ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
84 this->clear_target_();
85 }
86 // The door took the lamp key press but never reported the lamp changing, so stop expecting it to.
88 ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
90 }
91 if (this->changed_) {
92 this->changed_ = false;
93 this->state_callback_.call();
94 }
95}
96
98 ESP_LOGCONFIG(TAG,
99 "Hoermann HCP bridge:\n"
100 " Modbus server address: 0x%02X",
101 this->get_address());
102}
103
104modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
105 modbus::RegisterValues &registers) {
106 if (start_address != STATE_REG) {
107 ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address);
109 }
110
111 this->record_response_();
112
113 // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message
114 // counter (high byte) and command (low byte). The read length identifies which internal block is requested.
115 const uint16_t counter = this->command_reg_value_ & 0xFF00;
116 const uint16_t command = static_cast<uint16_t>((this->command_reg_value_ & 0x00FF) << 8);
117
118 switch (number_of_registers) {
119 case 8:
120 // Command request: return the internal state, injecting any pending command.
121 registers.push_back(counter);
122 registers.push_back(static_cast<uint16_t>(0x0001 | command));
123 this->push_command_registers_(registers);
124 push_zeros(registers, 4);
125 break;
126 case 2:
127 // Empty command request.
128 registers.push_back(static_cast<uint16_t>(0x0004 | counter));
129 registers.push_back(command);
130 break;
131 case 5:
132 // Bus scan (the bus controller discovering us, typically at startup).
133 ESP_LOGD(TAG, "Bus scan received from bus controller");
134 registers.push_back(counter);
135 registers.push_back(static_cast<uint16_t>(0x0005 | command));
136 registers.push_back(0x0430);
137 registers.push_back(0x10FF);
138 registers.push_back(0xA845);
139 break;
140 default:
141 ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers);
142 push_zeros(registers, number_of_registers);
143 break;
144 }
145
146 return {};
147}
148
150 const modbus::RegisterValues &registers) {
151 if (start_address == COMMAND_REG) {
152 // 0x17 write half: stash the command register so the following read half can echo its message counter and
153 // command byte back from STATE_REG. The hub always runs the write before the read within one request.
154 this->record_response_();
155 this->command_reg_value_ = registers[0];
156 return {};
157 }
158
159 if (start_address != BROADCAST_REG) {
160 // Every device sees every broadcast, so a frame meant for another node is ordinary traffic
161 ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address);
163 }
164
165 this->record_response_();
166
167 // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new
168 // position checks the target against the new state.
169 if (registers.size() > 2)
170 this->on_state_reg_(registers[2]);
171 if (registers.size() > 1)
172 this->on_position_reg_(registers[1]);
173 if (registers.size() > 6) {
174 this->on_light_reg_(registers[6]);
175 return {};
176 }
177 // Nothing refreshes the lamp any more, so what was read before must not be commanded against.
178 this->set_light_seen_(false);
179 if (!this->short_broadcast_logged_) {
180 this->short_broadcast_logged_ = true;
181 ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast<unsigned>(registers.size()));
182 }
183 return {};
184}
185
187 const HoermannHcpCommand *command = this->next_command_;
188 if (command == nullptr) {
189 push_zeros(registers, 2);
190 return;
191 }
192 if (this->command_written_at_ == 0) {
193 // First read after the command was queued: present the "key pressed" values.
194 this->command_written_at_ = millis();
195 ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
196 registers.push_back(command->pressed_value);
197 registers.push_back(command->pressed_value_2);
198 return;
199 }
201 // Between the two events there is nothing to report, including in the second register.
202 push_zeros(registers, 2);
203 return;
204 }
205 // Enough time passed: present the "key released" values and clear the command.
206 ESP_LOGD(TAG, "Released '%s' command", command->name);
207 this->command_written_at_ = 0;
208 this->next_command_ = nullptr;
209 // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
210 // to wait for, so it must not re-arm the watchdog.
211 if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
213 registers.push_back(command->released_value);
214 registers.push_back(command->released_value_2);
215}
216
217void HoermannHcp::on_position_reg_(uint16_t value) {
218 // Low byte: current position.
219 const uint8_t position = static_cast<uint8_t>(value);
220 if (this->position_raw_ == position)
221 return;
222
223 this->position_raw_ = position;
225 // Until the door actually travels the way it was told to, its position says nothing about the target.
226 if (!this->has_target_() || !this->target_started_)
227 return;
228
229 // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way.
230 const bool reached = this->target_direction_ == DoorState::OPENING
231 ? this->current_position_ >= this->target_position_
232 : this->current_position_ <= this->target_position_;
233 if (reached)
234 this->stop_door();
235}
236
237void HoermannHcp::on_state_reg_(uint16_t value) {
238 // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte.
239 const uint16_t previous = this->prev_state_reg_;
240 this->prev_state_reg_ = value;
241 if (previous == value)
242 return;
243
244 const uint8_t state = value >> 8;
245 if (state == 0x00) {
246 // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop.
247 this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED);
248 return;
249 }
250 for (const auto &mapping : DOOR_STATE_MAPPINGS) {
251 if (mapping.code == state) {
252 this->set_door_state_(mapping.state);
253 return;
254 }
255 }
256 // The low byte can change on its own, so only report a state we cannot decode once.
257 if (state != (previous >> 8))
258 ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
259}
260
261// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
262// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
263void HoermannHcp::on_light_reg_(uint16_t value) {
264 this->set_light_seen_(true);
265 this->set_light_on_((value & 0x0010) != 0);
266}
267
269 if (!this->valid_) {
270 // Queueing now would fire the command whenever the controller comes back, which may be much later.
271 ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
272 return false;
273 }
274 if (this->next_command_ != nullptr) {
275 ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
276 return false;
277 }
278 // A new command supersedes any half-open target the door was still travelling to.
279 if (command.clears_target)
280 this->clear_target_();
281 this->next_command_ = &command;
282 this->command_queued_at_ = millis();
283 return true;
284}
285
286bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
287bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
288bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
290 if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
291 ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
292 return false;
293 }
294 if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
295 return false;
297 return true;
298}
299bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
300
302 return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
303}
304
306 // Once the pressed value has been presented the key press is already on the wire, so only an untouched
307 // command can be withdrawn.
308 if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
309 return false;
310 ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
311 this->drop_command_();
312 return true;
313}
314
316 if (!is_moving(this->door_state_)) {
317 this->clear_target_();
318 return true;
319 }
320 // On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
321 return this->queue_command_(COMMAND_IMPULSE);
322}
323
325 // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed.
326 if (position <= CLOSE_POSITION_THRESHOLD)
327 return this->close_door();
328 if (position >= OPEN_POSITION_THRESHOLD)
329 return this->open_door();
330 // Asking the door to travel to where it already is means stopping it.
331 if (position == this->current_position_)
332 return this->stop_door();
333
334 // The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
335 const bool opening = position > this->current_position_;
336 if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
337 return false;
339 this->target_queued_at_ = millis();
341 // A door already travelling that way is on its way; one moving the other way has to turn around first.
342 this->target_started_ = this->door_state_ == this->target_direction_;
343 return true;
344}
345
347 this->last_response_ = millis();
348 this->set_valid_(true);
349}
350
352 if (this->valid_ == valid)
353 return;
354 this->valid_ = valid;
355 this->changed_ = true;
356 if (valid) {
357 ESP_LOGI(TAG, "Bus controller connected");
358 return;
359 }
360 ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
361 // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
362 this->drop_command_();
363 // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
364 this->clear_target_();
365 this->forget_light_toggles_();
366 // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
367 this->set_light_seen_(false);
368 this->short_broadcast_logged_ = false;
369}
370
372 const bool was_light_toggle = this->is_light_toggle_pending_();
373 // Cleared first so the settling below no longer counts this command among the toggles still to be sent.
374 this->next_command_ = nullptr;
375 this->command_written_at_ = 0;
376 if (was_light_toggle) {
377 // A lamp toggle says nothing about where the door was going, so it leaves the target alone.
378 this->light_toggle_settled_();
379 } else {
380 this->clear_target_();
381 }
382}
383
385 if (this->light_toggles_in_flight_ == 0)
386 return;
388 // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
391 // The light was showing where the lamp was heading, so it has to be told to look again.
392 this->changed_ = true;
393}
394
396 // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
398 // A toggle the door has not been shown yet is still going to fire, so it keeps counting.
399 const uint8_t unsent = this->unsent_light_toggles_();
400 if (this->light_toggles_in_flight_ == unsent)
401 return;
402 this->light_toggles_in_flight_ = unsent;
403 this->changed_ = true;
404}
405
407 if (this->door_state_ == state)
408 return;
409 this->door_state_ = state;
410 this->changed_ = true;
412 if (!this->has_target_())
413 return;
414 if (state == this->target_direction_) {
415 this->target_started_ = true;
416 } else if (this->target_started_ && !is_moving(state)) {
417 // The door came to rest without reaching the target, so the request it belonged to is over.
418 this->clear_target_();
419 }
420}
421
423 // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so
424 // trust the reported end stop over the raw count.
425 float position = static_cast<float>(this->position_raw_) / 200.0f;
426 if (this->door_state_ == DoorState::CLOSED) {
427 position = 0.0f;
428 } else if (this->door_state_ == DoorState::OPEN) {
429 position = 1.0f;
430 }
431 if (this->current_position_ != position) {
433 this->changed_ = true;
434 }
435}
436
438 this->target_position_ = 0.0f;
439 this->target_started_ = false;
440}
441
443 if (this->light_on_ == on)
444 return;
445 this->light_on_ = on;
446 this->changed_ = true;
448 // The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
449 ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
450 return;
451 }
452 // The door acted, so one of the toggles it has seen has arrived. Any others still count.
453 this->light_toggle_settled_();
454}
455
457 if (this->light_seen_ == seen)
458 return;
459 this->light_seen_ = seen;
460 // A resting door changes nothing else, so without this the light would never hear about it.
461 this->changed_ = true;
462}
463
464} // namespace esphome::hoermann_hcp
Minimal static vector - saves memory by avoiding std::vector overhead.
Definition helpers.h:227
size_t size() const
Definition helpers.h:292
void push_back(const T &value)
Definition helpers.h:265
void set_door_state_(DoorState state)
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues &registers) override
CallbackManager< void()> state_callback_
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues &registers) override
const HoermannHcpCommand * next_command_
void push_command_registers_(modbus::RegisterValues &registers)
bool queue_command_(const HoermannHcpCommand &command)
void on_position_reg_(uint16_t value)
float position
Definition cover.h:0
bool state
Definition fan.h:2
StaticVector< uint16_t, MAX_NUM_OF_REGISTERS_TO_READ > RegisterValues
Definition modbus.h:347
std::optional< ExceptionCode > ResponseStatus
Definition modbus.h:336
uint32_t IRAM_ATTR HOT millis()
Definition hal.cpp:28
bool valid
static void uint32_t