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