mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 07:20:24 +00:00
[hoermann_hcp] Add a door state text sensor (#19747)
This commit is contained in:
@@ -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 ®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<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 ®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};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user