[hoermann_hcp] Add buttons to hoermann_hcp (#18544)

This commit is contained in:
Josef Zweck
2026-08-26 14:27:16 -04:00
committed by GitHub
parent 34e7453677
commit f17ef133f3
6 changed files with 166 additions and 1 deletions
@@ -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)
@@ -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
@@ -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");
@@ -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();
@@ -0,0 +1,72 @@
#include <gtest/gtest.h>
#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
@@ -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