Compare commits

...
5 changed files with 217 additions and 9 deletions

No files matched your search

+2 -1
View File
@@ -443,7 +443,8 @@ file does, and it is the authority when they disagree. The most useful starting
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state,
and `publish="on_success"` publishes only when the hub call returns true (a write the device can refuse).
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
the body only when turned on. Never use it for a new entity; an action is a button.
```python
+18 -8
View File
@@ -5,7 +5,7 @@ from importlib import import_module
from importlib.util import find_spec
import logging
import re
from typing import Any, Literal
from typing import Any, Literal, get_args
from esphome.automation import apply_static_function, parent_ref
import esphome.codegen as cg
@@ -503,7 +503,8 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
config[_KEY_ICON_IDX] = icon_idx
ApplyPublish = Literal["before", "after", "momentary"] | None
ApplyPublishMode = Literal["before", "after", "momentary", "on_success"]
ApplyPublish = ApplyPublishMode | None
@dataclass(frozen=True, slots=True)
@@ -577,7 +578,8 @@ class SubEntities:
With ``apply`` the entity is the domain's ``Apply*`` class and its control runs
``parent->apply``, or ``apply`` as a namespaced free statement when no parent is bound, with
``publish`` ("before"/"after") placing the domain's publish. ``publish="momentary"`` instead
``publish`` ("before"/"after") placing the domain's publish; ``"on_success"`` publishes only when
``apply`` returns true. ``publish="momentary"`` instead
acknowledges first (a switch publishes off) and runs ``apply`` only when turned on. It is only for
legacy action switches; a new action entity is a button.
"""
@@ -600,10 +602,8 @@ class SubEntities:
)
if publish is not None and (apply is None or self.apply_entity.publish is None):
raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain")
if publish not in (None, "before", "after", "momentary"):
raise ValueError(
f'{entity_id}: publish= must be "before", "after" or "momentary"'
)
if publish is not None and publish not in (modes := get_args(ApplyPublishMode)):
raise ValueError(f"{entity_id}: publish= must be one of {', '.join(modes)}")
if publish == "momentary" and self.apply_entity.momentary is None:
raise ValueError(f'{entity_id}: publish="momentary" is not supported here')
@@ -626,11 +626,21 @@ class SubEntities:
if isinstance(parent, ID):
parent = await get_variable(parent)
apply_entity = self.apply_entity
call = f"{apply};" if parent is None else f"{parent_ref(parent)}->{apply};"
expr = apply if parent is None else f"{parent_ref(parent)}->{apply}"
call = f"{expr};"
if publish == "before":
body = [apply_entity.publish, call]
elif publish == "after":
body = [call, apply_entity.publish]
elif publish == "on_success":
# An esp_err_t or int would convert to bool silently and flip the meaning
body = [
(
f"static_assert(std::is_same_v<decltype({expr}), bool>, "
'"publish=\\"on_success\\" needs a hub call that returns bool");'
),
f"if ({expr}) {{ {apply_entity.publish} }}",
]
elif publish == "momentary":
acknowledge, condition = apply_entity.momentary
body = [acknowledge, f"if ({condition}) {{ {call} }}"]
+14
View File
@@ -0,0 +1,14 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# One empty config entry so to_code runs and compiles the PG/GPO switch path.
manifest.multi_conf = False
manifest.dependencies = manifest.dependencies + ["i2c", "switch"]
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_BQ25186_PG_GPO")
manifest.to_code = to_code
@@ -0,0 +1,136 @@
#include <gtest/gtest.h>
#include <array>
#include "esphome/components/bq25186/bq25186.h"
#include "esphome/components/bq25186/bq25186_switch.h"
namespace esphome::bq25186::testing {
static constexpr uint8_t ADDRESS = 0x6A;
static constexpr uint8_t SYS_REG_OTHER_BITS = 0x0F;
// The chip's registers behind an address pointer that moves on after every byte; writes can be made to fail.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (size > 1 && this->fail_writes)
return i2c::ERROR_NOT_ACKNOWLEDGED;
if (size != 0) {
this->pointer_ = data[0];
for (size_t i = 1; i < size; i++)
this->registers[this->next_()] = data[i];
}
for (size_t i = 0; i < out_size; i++)
out[i] = this->registers[this->next_()];
return i2c::ERROR_OK;
}
std::array<uint8_t, BQ25186_STATUS_REGISTER_COUNT> registers{};
bool fail_writes{false};
protected:
size_t next_() { return this->pointer_++ % this->registers.size(); }
uint8_t pointer_{0};
};
class BQ25186PgGpoTest : public ::testing::Test {
protected:
void SetUp() override {
this->bus_.registers[BQ25186_REG_MASK_ID] = 0x01; // the BQ25186 device id
this->bus_.registers[BQ25186_REG_SYS_REG] = SYS_REG_OTHER_BITS;
this->hub_.set_i2c_bus(&this->bus_);
this->hub_.set_i2c_address(ADDRESS);
this->switch_.set_parent(&this->hub_);
this->switch_.set_restore_mode(switch_::SWITCH_RESTORE_DISABLED);
this->hub_.set_pg_gpo_switch(&this->switch_);
}
bool level_() const { return (this->bus_.registers[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0; }
uint8_t other_bits_() const { return this->bus_.registers[BQ25186_REG_SYS_REG] & ~BQ25186_SYS_REG_PG_GPO_LEVEL; }
RegisterBus bus_;
BQ25186Component hub_;
BQ25186PgGpoSwitch switch_;
};
TEST_F(BQ25186PgGpoTest, TurnOnSetsTheLevelAndPublishes) {
this->switch_.turn_on();
EXPECT_TRUE(this->level_());
EXPECT_EQ(this->other_bits_(), SYS_REG_OTHER_BITS);
EXPECT_TRUE(this->switch_.has_state());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, TurnOffClearsTheLevelAndPublishes) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.publish_state(true);
this->switch_.turn_off();
EXPECT_FALSE(this->level_());
EXPECT_EQ(this->other_bits_(), SYS_REG_OTHER_BITS);
EXPECT_FALSE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, FailedWriteLeavesTheStateUnpublished) {
this->bus_.fail_writes = true;
this->switch_.turn_on();
EXPECT_FALSE(this->level_());
EXPECT_FALSE(this->switch_.has_state());
}
TEST_F(BQ25186PgGpoTest, FailedWriteKeepsThePreviousState) {
// Start from on, so a reset to the default off state would show
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.publish_state(true);
this->bus_.fail_writes = true;
this->switch_.turn_off();
EXPECT_TRUE(this->level_());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, InvertedSwitchDrivesTheOppositeLevel) {
this->switch_.set_inverted(true);
this->switch_.turn_on();
EXPECT_FALSE(this->level_());
EXPECT_TRUE(this->switch_.state);
this->switch_.turn_off();
EXPECT_TRUE(this->level_());
EXPECT_FALSE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWritesTheRestoredLevel) {
this->switch_.set_restore_mode(switch_::SWITCH_ALWAYS_ON);
this->hub_.setup();
EXPECT_TRUE(this->level_());
EXPECT_NE(this->bus_.registers[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE, 0);
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWritesTheRestoredLevelInverted) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.set_restore_mode(switch_::SWITCH_ALWAYS_ON);
this->switch_.set_inverted(true);
this->hub_.setup();
EXPECT_FALSE(this->level_());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWithoutRestoreTakesTheChipLevel) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->hub_.setup();
EXPECT_TRUE(this->level_());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWithoutRestoreTakesTheChipLevelInverted) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.set_inverted(true);
this->hub_.setup();
EXPECT_TRUE(this->level_());
EXPECT_FALSE(this->switch_.state);
}
} // namespace esphome::bq25186::testing
@@ -1711,6 +1711,16 @@ _SELECT_APPLY = entity_helpers.ApplyEntity(
"after",
"::hub->set_mode(index, options_);\nentity->publish_state(index);",
),
(
"set_mode(option)",
"on_success",
(
"const char *option = entity->option_at(index);\n"
"static_assert(std::is_same_v<decltype(::hub->set_mode(option)), bool>, "
'"publish=\\"on_success\\" needs a hub call that returns bool");\n'
"if (::hub->set_mode(option)) { entity->publish_state(index); }"
),
),
],
)
async def test_sub_entities_apply_locals(
@@ -1883,6 +1893,43 @@ async def test_sub_entities_apply_momentary(
assert apply_functions[0][2] == body
@pytest.mark.asyncio
@pytest.mark.parametrize(
("parent", "apply", "body"),
[
(
_HUB,
"write_level(state)",
(
"static_assert(std::is_same_v<decltype(::hub->write_level(state)), bool>, "
'"publish=\\"on_success\\" needs a hub call that returns bool");\n'
"if (::hub->write_level(state)) { entity->publish_state(state); }"
),
),
(
None,
"chip::write_level(state)",
(
"static_assert(std::is_same_v<decltype(chip::write_level(state)), bool>, "
'"publish=\\"on_success\\" needs a hub call that returns bool");\n'
"if (chip::write_level(state)) { entity->publish_state(state); }"
),
),
],
)
async def test_sub_entities_apply_on_success(
setup_test_environment: list[str],
apply_functions: list[tuple[str, str, str]],
parent: MockObj | None,
apply: str,
body: str,
) -> None:
"""publish="on_success" publishes only when the hub call returns true."""
sub = _apply_sub([], {"k": _apply_conf("k_sw")}, parent=parent)
await sub("k", None, apply=apply, publish="on_success")
assert apply_functions[0][2] == body
@pytest.mark.asyncio
async def test_sub_entities_apply_without_parent_needs_namespaced_call(
setup_test_environment: list[str],