diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py new file mode 100644 index 0000000000..3de6a161e6 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_IS_CONNECTED = "is_connected" + +HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( + "HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema( + HoermannHcpConnectedBinarySensor, + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.COMPONENT_SCHEMA), + } + ), + cv.has_at_least_one_key(CONF_IS_CONNECTED), +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_IS_CONNECTED)) is not None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await binary_sensor.new_binary_sensor(conf, parent) + await cg.register_component(var, conf) diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp new file mode 100644 index 0000000000..edce6ce4c2 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -0,0 +1,18 @@ +#include "hoermann_hcp_binary_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.binary_sensor"; + +void HoermannHcpConnectedBinarySensor::setup() { + // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive + // their timers. + this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); }); + this->publish_initial_state(this->parent_->is_valid()); +} + +void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); } + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h new file mode 100644 index 0000000000..c111c17834 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + HoermannHcp *const parent_; +}; + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp new file mode 100644 index 0000000000..3cf708c19e --- /dev/null +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -0,0 +1,72 @@ +#include + +#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// Exposes the connection bookkeeping so a drop can be driven without waiting one out. +class TestableHoermannHcp : public HoermannHcp { + public: + using HoermannHcp::set_valid_; +}; + +} // namespace + +// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. +TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +// The connection flag follows the bus controller in both directions. +TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { + TestableHoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + ASSERT_FALSE(sensor.state); + + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.set_valid_(false); + door.update(); + EXPECT_FALSE(sensor.state); +} + +// Any hub change re-runs the publish path, so an unchanged connection must not be reported twice. +TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + ASSERT_EQ(publishes, 1); + + // A status broadcast changes the door state without touching the connection. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + EXPECT_EQ(publishes, 1); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 3b77eed7ea..84162e8812 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -6,3 +6,8 @@ cover: - platform: hoermann_hcp name: Garage Door device_class: garage + +binary_sensor: + - platform: hoermann_hcp + is_connected: + name: Garage Connected