diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 261996e557..9ab7014e0e 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -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(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 ®isters) { 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 ®isters, uint16_t command) { if (static_cast(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; diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index fd52a689d0..9ebb717d0c 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -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 ®isters, 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}; diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index 0fcbe60040..2f038025b9 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -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)) diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp new file mode 100644 index 0000000000..765a80ae6f --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp @@ -0,0 +1,49 @@ +#include "hoermann_hcp_text_sensor.h" + +#include + +#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(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( + DoorStateStrings::get_progmem_str(static_cast(state), DoorStateStrings::LAST_INDEX)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h new file mode 100644 index 0000000000..a3fb91cd9a --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h @@ -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 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f768777c04..29b686e03c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h index fb940d790f..bc9975f2e8 100644 --- a/tests/components/hoermann_hcp/common.h +++ b/tests/components/hoermann_hcp/common.h @@ -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_; diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 394afed360..21d3d2b848 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -25,6 +25,8 @@ light: text_sensor: - platform: hoermann_hcp + door_state: + name: Garage Door State serial_number: name: Garage Motor Serial Number version: diff --git a/tests/components/hoermann_hcp/text_sensor/__init__.py b/tests/components/hoermann_hcp/text_sensor/__init__.py index 693c48b736..20e7124507 100644 --- a/tests/components/hoermann_hcp/text_sensor/__init__.py +++ b/tests/components/hoermann_hcp/text_sensor/__init__.py @@ -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 diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp new file mode 100644 index 0000000000..dfb3ba0e92 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp @@ -0,0 +1,125 @@ +#include + +#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 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