diff --git a/esphome/components/hoermann_hcp/button/__init__.py b/esphome/components/hoermann_hcp/button/__init__.py new file mode 100644 index 0000000000..dc2efcec44 --- /dev/null +++ b/esphome/components/hoermann_hcp/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ICON_AIR_FILTER +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_HALF_OPEN = "half_open" +CONF_VENT = "vent" + +ICON_GARAGE_OPEN_VARIANT = "mdi:garage-open-variant" + +HoermannHcpVentButton = hoermann_hcp_ns.class_("HoermannHcpVentButton", button.Button) +HoermannHcpHalfOpenButton = hoermann_hcp_ns.class_( + "HoermannHcpHalfOpenButton", button.Button +) + +BUTTON_KEYS = (CONF_VENT, CONF_HALF_OPEN) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_VENT): button.button_schema( + HoermannHcpVentButton, icon=ICON_AIR_FILTER + ), + cv.Optional(CONF_HALF_OPEN): button.button_schema( + HoermannHcpHalfOpenButton, icon=ICON_GARAGE_OPEN_VARIANT + ), + } + ), + cv.has_at_least_one_key(*BUTTON_KEYS), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + for key in BUTTON_KEYS: + if (conf := config.get(key)) is not None: + await button.new_button(conf, parent) diff --git a/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h b/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h new file mode 100644 index 0000000000..e9ebceee88 --- /dev/null +++ b/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +// The door commands the cover has no equivalent for. A refused command is already reported by the hub and +// leaves nothing to correct here, because a button carries no state of its own. +class HoermannHcpButton : public button::Button { + public: + explicit HoermannHcpButton(HoermannHcp *parent) : parent_(parent) {} + + protected: + HoermannHcp *const parent_; +}; + +class HoermannHcpVentButton final : public HoermannHcpButton { + public: + using HoermannHcpButton::HoermannHcpButton; + + protected: + void press_action() override { this->parent_->vent_door(); } +}; + +class HoermannHcpHalfOpenButton final : public HoermannHcpButton { + public: + using HoermannHcpButton::HoermannHcpButton; + + protected: + void press_action() override { this->parent_->half_open_door(); } +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index a780854831..17df927eb7 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -22,6 +22,9 @@ static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The intermediate positions are named in the second register, so the first only carries the phase. +static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000}; +static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400}; // The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; @@ -286,6 +289,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); } +bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); } bool HoermannHcp::toggle_light() { if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 41fd7617e4..83be385c7b 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -22,7 +22,8 @@ enum class DoorState : uint8_t { }; // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a -// short delay the released value. Each half also carries a second register, which only the lamp command uses. +// short delay the released value. Each half also carries a second register, which names the buttons that do +// not fit into the first. struct HoermannHcpCommand { const char *name; uint16_t pressed_value; @@ -54,6 +55,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool open_door(); bool close_door(); bool impulse_door(); + // The door drives to these intermediate positions on its own, so neither takes a target to be stopped at. + bool vent_door(); + bool half_open_door(); bool stop_door(); bool set_position(float position); bool toggle_light(); diff --git a/tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp b/tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp new file mode 100644 index 0000000000..9c38bc1708 --- /dev/null +++ b/tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp @@ -0,0 +1,72 @@ +#include + +#include "esphome/components/hoermann_hcp/button/hoermann_hcp_button.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +// The intermediate positions are named in the second register, which repeats that name on release. +TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) { + TestableHoermannHcp door; + HoermannHcpVentButton vent(&door); + connect_controller(door); + + vent.press(); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0200); + EXPECT_EQ(pressed_2, 0x4000); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0100); + EXPECT_EQ(released_2, 0x4000); +} + +TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) { + TestableHoermannHcp door; + HoermannHcpHalfOpenButton half_open(&door); + connect_controller(door); + + half_open.press(); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0200); + EXPECT_EQ(pressed_2, 0x0400); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0100); + EXPECT_EQ(released_2, 0x0400); +} + +// The door drives to the vent position on its own, so a position the cover was still travelling to must not +// stop it on the way there. +TEST(HoermannHcpButtonTest, VentAbandonsAnArmedTarget) { + TestableHoermannHcp door; // starts out fully closed + HoermannHcpVentButton vent(&door); + connect_controller(door); + door.set_position(0.5f); + consume_command(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + vent.press(); + consume_command(door); + + // Position 120/200 = 0.6 is past the abandoned target, which must no longer stop the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// A button carries no state, so a refused press is simply dropped rather than fired once the controller +// turns up, which could be much later. +TEST(HoermannHcpButtonTest, PressWithoutABusControllerSendsNothing) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpVentButton vent(&door); + + vent.press(); + + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 552b1cb0fd..618a8181bf 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -12,6 +12,13 @@ binary_sensor: is_connected: name: Garage Connected +button: + - platform: hoermann_hcp + vent: + name: Garage Vent + half_open: + name: Garage Half Open + light: - platform: hoermann_hcp name: Garage Light