[hoermann_hcp] Add a door state text sensor (#19747)

This commit is contained in:
Raphael Hehl
2026-09-27 07:48:36 +01:00
committed by GitHub
parent 514ee178f9
commit c26f2d28e6
10 changed files with 243 additions and 18 deletions
@@ -63,7 +63,7 @@ static bool is_moving(DoorState state) {
}
}
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
// The command byte of a status poll. Only its answer can carry a request.
static constexpr uint8_t STATUS_COMMAND = 0x03;
// A status answer with this code in the low byte of its second register asks the bus controller for a value,
@@ -149,7 +149,7 @@ void HoermannHcp::update() {
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
this->forget_light_toggles_();
}
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
this->publish_identity_();
#endif
if (this->changed_) {
@@ -163,7 +163,7 @@ void HoermannHcp::dump_config() {
"Hoermann HCP bridge:\n"
" Modbus server address: 0x%02X",
this->get_address());
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_);
log_identity_value(this->serial_number_text_sensor_);
LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_);
@@ -180,7 +180,7 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add
this->record_response_();
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
// Acknowledge the transfer taken by the write half of this frame.
if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) {
this->push_transfer_answer_(registers, number_of_registers);
@@ -200,7 +200,7 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add
registers.push_back(static_cast<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
push_zeros(registers, 4);
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
this->add_identity_request_(registers, command);
#endif
break;
@@ -234,7 +234,7 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
// command byte back from STATE_REG. The hub always runs the write before the read within one request.
this->record_response_();
this->command_reg_value_ = registers[0];
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
this->transfer_answer_counter_ = this->take_identity_transfer_(registers);
#endif
return {};
@@ -298,7 +298,7 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
registers.push_back(command->released_value_2);
}
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
void HoermannHcp::add_identity_request_(modbus::RegisterValues &registers, uint16_t command) {
if (static_cast<uint8_t>(this->command_reg_value_) != STATUS_COMMAND)
return;
@@ -601,6 +601,9 @@ void HoermannHcp::set_valid_(bool valid) {
this->forget_light_toggles_();
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
this->set_light_seen_(false);
// The same holds for the door. The next broadcast is decoded even if it repeats the last one.
this->door_state_seen_ = false;
this->prev_state_reg_ = 0xFFFF;
this->short_broadcast_logged_ = false;
}
@@ -640,6 +643,11 @@ void HoermannHcp::forget_light_toggles_() {
}
void HoermannHcp::set_door_state_(DoorState state) {
// The first state may equal the default, so being seen is a change of its own.
if (!this->door_state_seen_) {
this->door_state_seen_ = true;
this->changed_ = true;
}
if (this->door_state_ == state)
return;
this->door_state_ = state;
@@ -6,7 +6,7 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
#include "esphome/components/text_sensor/text_sensor.h"
#endif
@@ -25,7 +25,7 @@ enum class DoorState : uint8_t {
STOPPED,
};
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
// Payload registers of each value, two bytes each.
static constexpr size_t SERIAL_FIRST_HALF_REGS = 7;
static constexpr size_t SERIAL_SECOND_HALF_REGS = 6;
@@ -61,7 +61,7 @@ struct HoermannHcpCommand {
};
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
// The motor is asked for these only when one of them is configured.
SUB_TEXT_SENSOR(serial_number)
SUB_TEXT_SENSOR(version)
@@ -95,6 +95,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
bool toggle_light();
DoorState get_door_state() const { return this->door_state_; }
// False until a broadcast has carried a state the door is known to report. Bus traffic alone makes the
// connection valid, so get_door_state() would still be its default.
bool is_door_state_known() const { return this->door_state_seen_; }
float get_current_position() const { return this->current_position_; }
bool is_valid() const { return this->valid_; }
bool is_light_on() const { return this->light_on_; }
@@ -127,7 +130,7 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
void on_position_reg_(uint16_t value);
void on_state_reg_(uint16_t value);
void on_light_reg_(uint16_t value);
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
// Puts a due request into a status answer.
void add_identity_request_(modbus::RegisterValues &registers, uint16_t command);
void arm_identity_request_(IdentityPhase phase);
@@ -192,9 +195,10 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
bool changed_{false};
bool light_on_{false};
bool light_seen_{false};
bool door_state_seen_{false};
bool short_broadcast_logged_{false};
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
uint32_t identity_asked_at_{0};
IdentityPhase identity_phase_{IdentityPhase::IDENTITY_PHASE_IDLE};
uint8_t identity_attempts_{0};
@@ -4,16 +4,24 @@ import esphome.config_validation as cv
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
from esphome.types import ConfigType
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
CONF_DOOR_STATE = "door_state"
CONF_SERIAL_NUMBER = "serial_number"
HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_(
"HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp),
cv.Optional(CONF_DOOR_STATE): text_sensor.text_sensor_schema(
HoermannHcpDoorStateTextSensor, icon="mdi:garage"
).extend(cv.COMPONENT_SCHEMA),
cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(
icon="mdi:data-matrix", entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
@@ -22,13 +30,18 @@ CONFIG_SCHEMA = cv.All(
),
}
),
cv.has_at_least_one_key(CONF_SERIAL_NUMBER, CONF_VERSION),
cv.has_at_least_one_key(CONF_DOOR_STATE, CONF_SERIAL_NUMBER, CONF_VERSION),
)
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR")
if (conf := config.get(CONF_DOOR_STATE)) is not None:
var = await text_sensor.new_text_sensor(conf, parent)
await cg.register_component(var, conf)
# Only the identity sensors need the exchange with the motor compiled in.
if CONF_SERIAL_NUMBER in config or CONF_VERSION in config:
cg.add_define("USE_HOERMANN_HCP_IDENTITY")
if (conf := config.get(CONF_SERIAL_NUMBER)) is not None:
sens = await text_sensor.new_text_sensor(conf)
cg.add(parent.set_serial_number_text_sensor(sens))
@@ -0,0 +1,49 @@
#include "hoermann_hcp_text_sensor.h"
#include <cstring>
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp.text_sensor";
// Indexed by DoorState. Each fits the 15 characters std::string keeps inline, so publishing never allocates.
PROGMEM_STRING_TABLE(DoorStateStrings, "Open", "Opening", "Closed", "Closing", "Half open", "Moving to vent",
"Vent position", "Moving to half", "Stopped");
static_assert(DoorStateStrings::COUNT == static_cast<size_t>(DoorState::STOPPED) + 1,
"DoorStateStrings must list every DoorState in order");
void HoermannHcpDoorStateTextSensor::setup() {
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
// A state decoded before the callback was added would otherwise wait for the next change.
this->update_from_state_();
}
void HoermannHcpDoorStateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Hoermann HCP Door State", this); }
void HoermannHcpDoorStateTextSensor::update_from_state_() {
// The last state stays while the bus controller is gone, and is published again once it is back.
if (!this->parent_->is_valid()) {
this->published_ = false;
return;
}
if (!this->parent_->is_door_state_known())
return;
// Any hub change runs this, so only a new door state is published.
const DoorState state = this->parent_->get_door_state();
if (this->published_ && state == this->published_state_)
return;
this->published_ = true;
this->published_state_ = state;
char text[16];
ESPHOME_strncpy_P(text,
reinterpret_cast<ESPHOME_PGM_P>(
DoorStateStrings::get_progmem_str(static_cast<uint8_t>(state), DoorStateStrings::LAST_INDEX)),
sizeof(text));
text[sizeof(text) - 1] = '\0';
this->publish_state(text);
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/component.h"
#include "../hoermann_hcp.h"
namespace esphome::hoermann_hcp {
class HoermannHcpDoorStateTextSensor : public text_sensor::TextSensor, public Component {
public:
explicit HoermannHcpDoorStateTextSensor(HoermannHcp *parent) : parent_(parent) {}
void setup() override;
void dump_config() override;
protected:
void update_from_state_();
HoermannHcp *const parent_;
DoorState published_state_{DoorState::CLOSED};
bool published_{false};
};
} // namespace esphome::hoermann_hcp
+1 -1
View File
@@ -77,7 +77,7 @@
#define USE_GPIO_SWITCH_INTERLOCK
#define USE_GRAPH
#define USE_GRAPHICAL_DISPLAY_MENU
#define USE_HOERMANN_HCP_TEXT_SENSOR
#define USE_HOERMANN_HCP_IDENTITY
#define USE_HOMEASSISTANT_TIME
#define USE_HOMEASSISTANT_TIMEZONE
#define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT
+1 -1
View File
@@ -66,7 +66,7 @@ class TestableHoermannHcp : public HoermannHcp {
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
using HoermannHcp::connection_timeout_ms_;
#ifdef USE_HOERMANN_HCP_TEXT_SENSOR
#ifdef USE_HOERMANN_HCP_IDENTITY
using HoermannHcp::identity_asked_at_;
using HoermannHcp::identity_request_;
using HoermannHcp::firmware_unreadable_;
@@ -25,6 +25,8 @@ light:
text_sensor:
- platform: hoermann_hcp
door_state:
name: Garage Door State
serial_number:
name: Garage Motor Serial Number
version:
@@ -6,6 +6,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The platform's own to_code needs a configured hub; only its define is wanted here.
async def to_code_testing(config: ConfigType) -> None:
cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR")
cg.add_define("USE_HOERMANN_HCP_IDENTITY")
manifest.to_code = to_code_testing
@@ -0,0 +1,125 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
namespace {
// A status broadcast with the door state in the high byte of its third register.
void broadcast_state(HoermannHcp &door, uint16_t state_reg) {
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, state_reg}));
door.update();
}
struct DoorStateFixture {
DoorStateFixture() {
this->sensor.setup();
this->sensor.add_on_state_callback([this](const std::string & /*state*/) { this->publishes++; });
}
TestableHoermannHcp door;
HoermannHcpDoorStateTextSensor sensor{&door};
int publishes{0};
};
} // namespace
// Polls and bus scans make the connection valid before any broadcast has said where the door is, and an
// undecodable state says nothing either. None of these may show the default state.
TEST(HoermannHcpDoorStateTest, NothingBeforeTheDoorReportsAState) {
DoorStateFixture fixture;
auto &door = fixture.door;
door.update();
EXPECT_FALSE(fixture.sensor.has_state());
status_answer(door, 0x0003);
door.update();
ASSERT_TRUE(door.is_valid());
EXPECT_FALSE(fixture.sensor.has_state());
RegisterValues scan;
door.on_read_holding_registers(STATE_REG, 5, scan);
door.update();
EXPECT_FALSE(fixture.sensor.has_state());
broadcast_state(door, 0x1000);
EXPECT_FALSE(fixture.sensor.has_state());
// The first real state is shown even when it equals the default.
broadcast_state(door, 0x4000);
EXPECT_EQ(fixture.sensor.get_state(), "Closed");
}
// Every state the door reports is shown, including the vent and half-open positions and the moves to them.
TEST(HoermannHcpDoorStateTest, FollowsTheDoorState) {
DoorStateFixture fixture;
auto &door = fixture.door;
connect_controller(door);
const std::pair<uint16_t, const char *> states[] = {
{0x2000, "Open"}, {0x0200, "Closing"}, {0x4000, "Closed"}, {0x0900, "Moving to vent"},
{0x0A00, "Vent position"}, {0x0500, "Moving to half"}, {0x8000, "Half open"}, {0x0100, "Opening"},
{0x0000, "Stopped"}, {0x0061, "Vent position"},
};
for (const auto &[reg, text] : states) {
broadcast_state(door, reg);
EXPECT_EQ(fixture.sensor.get_state(), text) << "state register 0x" << std::hex << reg;
}
}
// Any hub change runs the publish path, so an unchanged door state is not published again, and a state the
// door is not known to report keeps the last one.
TEST(HoermannHcpDoorStateTest, EachStateIsPublishedOnce) {
DoorStateFixture fixture;
auto &door = fixture.door;
connect_controller(door);
broadcast_state(door, 0x2000);
ASSERT_EQ(fixture.publishes, 1);
// The lamp changes, the door state does not.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x2000, 0x0000, 0x0000, 0x0000, 0x0010}));
door.update();
EXPECT_EQ(fixture.publishes, 1);
broadcast_state(door, 0x1000);
EXPECT_EQ(fixture.publishes, 1);
EXPECT_EQ(fixture.sensor.get_state(), "Open");
}
// While the bus controller is gone the last state stays. Once it is back, a poll alone shows nothing new; the
// next broadcast is published again even if it repeats the old state.
TEST(HoermannHcpDoorStateTest, LastStateStaysUntilTheNextBroadcast) {
DoorStateFixture fixture;
auto &door = fixture.door;
connect_controller(door);
broadcast_state(door, 0x8000);
ASSERT_EQ(fixture.publishes, 1);
door.set_valid_(false);
door.update();
EXPECT_EQ(fixture.sensor.get_state(), "Half open");
connect_controller(door);
door.update();
EXPECT_EQ(fixture.publishes, 1);
broadcast_state(door, 0x8000);
EXPECT_EQ(fixture.publishes, 2);
EXPECT_EQ(fixture.sensor.get_state(), "Half open");
}
// A sensor set up after the hub already decoded a state shows it right away.
TEST(HoermannHcpDoorStateTest, LateSetupShowsTheCurrentState) {
TestableHoermannHcp door;
connect_controller(door);
broadcast_state(door, 0x0A00);
HoermannHcpDoorStateTextSensor sensor(&door);
sensor.setup();
EXPECT_EQ(sensor.get_state(), "Vent position");
}
} // namespace esphome::hoermann_hcp::testing