diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 6fd5f481e7..077dd03ccc 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -4,8 +4,12 @@ import esphome.codegen as cg from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv +from esphome.const import CONF_PLATFORM +import esphome.final_validate as fv from esphome.types import ConfigType +CONF_TMP102_ID = "tmp102_id" + DOMAIN = "tmp102" tmp102_ns = cg.esphome_ns.namespace("tmp102") @@ -83,3 +87,18 @@ def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: [CONF_TEMPERATURE_LOW], ) return config + + +def validate_unique_child(config: ConfigType, platform: str) -> ConfigType: + entries = fv.full_config.get().get(platform, []) + matches = [ + entry + for entry in entries + if entry.get(CONF_PLATFORM) == DOMAIN + and entry.get(CONF_TMP102_ID) == config[CONF_TMP102_ID] + ] + if len(matches) > 1: + raise cv.Invalid( + f"Only one TMP102 {platform} may reference each temperature sensor" + ) + return config diff --git a/esphome/components/tmp102/binary_sensor.py b/esphome/components/tmp102/binary_sensor.py new file mode 100644 index 0000000000..eb23206b98 --- /dev/null +++ b/esphome/components/tmp102/binary_sensor.py @@ -0,0 +1,28 @@ +"""TMP102 comparator alert entity.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "binary_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_BINARY_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(parent.set_alert_binary_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index a1066b9761..09b29dd7ef 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -10,8 +10,12 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; -#ifdef USE_TMP102_CONFIGURE +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +#endif +#ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; @@ -20,7 +24,6 @@ static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; -static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; @@ -44,6 +47,9 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -83,13 +89,13 @@ void TMP102Component::update() { } #endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } raw_temperature = i2c::i2ctohs(raw_temperature); @@ -98,6 +104,10 @@ void TMP102Component::update() { ESP_LOGD(TAG, "Got Temperature=%.1f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); }); } @@ -108,7 +118,7 @@ void TMP102Component::update_configured_() { return; if (this->config_ & TMP102_SHUTDOWN_BIT) { if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); @@ -134,7 +144,7 @@ bool TMP102Component::write_configuration_() { bool TMP102Component::check_configuration_() { uint16_t config; if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); @@ -144,7 +154,7 @@ bool TMP102Component::check_configuration_() { uint16_t high, low; if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } matches = high == this->high_limit_ && low == this->low_limit_; @@ -153,7 +163,7 @@ bool TMP102Component::check_configuration_() { return true; ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); } @@ -163,14 +173,38 @@ bool TMP102Component::check_configuration_() { void TMP102Component::read_temperature_() { uint16_t raw; if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } const float temperature = decode_temperature(raw, raw & 0x0001); ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); } #endif +#ifdef USE_TMP102_BINARY_SENSOR +void TMP102Component::invalidate_alert_() { + if (this->alert_binary_sensor_ != nullptr) + this->alert_binary_sensor_->invalidate_state(); +} + +bool TMP102Component::read_alert_state_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + ESP_LOGW(TAG, "Failed to read alert state"); + return false; + } + // AL equals POL while the comparator status is active. + const bool alert = bool(config & TMP102_ALERT_BIT) == bool(config & TMP102_ALERT_POLARITY_BIT); + this->alert_binary_sensor_->publish_state(alert); + return true; +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index b14b6c4cc1..caf75e19be 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -2,6 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#ifdef USE_TMP102_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" @@ -11,6 +14,9 @@ namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_BINARY_SENSOR + SUB_BINARY_SENSOR(alert) +#endif #ifdef USE_TMP102_CONFIGURE void setup() override; void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { @@ -24,6 +30,16 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: + void set_warning_and_invalidate_alert_() { + this->status_set_warning(); +#ifdef USE_TMP102_BINARY_SENSOR + this->invalidate_alert_(); +#endif + } +#ifdef USE_TMP102_BINARY_SENSOR + bool read_alert_state_(); + void invalidate_alert_(); +#endif #ifdef USE_TMP102_CONFIGURE bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d219296ca5..28abdb2218 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -244,6 +244,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER diff --git a/tests/component_tests/tmp102/config/alert.yaml b/tests/component_tests/tmp102/config/alert.yaml new file mode 100644 index 0000000000..3e1e42197d --- /dev/null +++ b/tests/component_tests/tmp102/config/alert.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + name: Alert diff --git a/tests/component_tests/tmp102/config/duplicate_alert.yaml b/tests/component_tests/tmp102/config/duplicate_alert.yaml new file mode 100644 index 0000000000..c56d0d96fa --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_alert.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + tmp102_id: chip + name: Alert One + - platform: tmp102 + tmp102_id: chip + name: Alert Two diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 020303d455..49f2629baf 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -7,6 +7,7 @@ import pytest from esphome.components import tmp102 as component from esphome.components.tmp102.sensor import CONFIG_SCHEMA +from esphome.config import read_config import esphome.config_validation as cv from esphome.core import CORE @@ -32,6 +33,31 @@ def test_advanced_options_enable_configuration_once( assert "USE_TMP102_CONFIGURE" in defines +def test_alert_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("alert.yaml")) + assert "set_alert_binary_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_BINARY_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_alert_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_alert.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "Only one TMP102 binary_sensor may reference each temperature sensor" + in captured.out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 9702134e8c..f86177c0f9 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,8 +6,10 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - CORE.testing_ensure_platform_registered("sensor") + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") + cg.add_define("USE_TMP102_BINARY_SENSOR") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] + manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index c764eae42b..1e5f1f456c 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -15,3 +15,8 @@ sensor: fault_queue: 6 temperature_high: 140°C temperature_low: 120°C + +binary_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Alert diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index b148b07716..cd06d8a83d 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -47,6 +47,7 @@ class TMP102Test : public ::testing::Test { protected: RegisterBus bus_; TMP102Component chip_; + binary_sensor::BinarySensor alert_; unsigned publications_{0}; void SetUp() override { @@ -112,6 +113,83 @@ TEST_F(TMP102Test, WritableConfigurationFields) { EXPECT_EQ(this->bus_.registers[2], 0x2580); } +TEST_F(TMP102Test, AlertEntityPreservesHardwareAndUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + for (uint16_t config : {0x60A0, 0x6080, 0x64A0, 0x6480}) { + this->bus_.registers[1] = config; + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x20) == bool(config & 0x400)); + EXPECT_EQ(this->bus_.registers[1], config); + } + EXPECT_EQ(this->bus_.writes, 0); +} + +TEST_F(TMP102Test, FailedTemperatureOrAlertReadInvalidatesAlert) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + for (int reg : {0, 1}) { + this->bus_.fail_read = reg; + this->chip_.update(); + run_timers(55); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); + this->bus_.fail_read = -1; + this->chip_.update(); + run_timers(55); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_TRUE(this->alert_.has_state()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + for (uint16_t config : {0x0080, 0x00A0, 0x0480, 0x04A0}) { + this->configure(config & ~0x0020); + this->chip_.setup(); + this->bus_.registers[1] = config; + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x0020) == bool(config & 0x0400)); + EXPECT_FALSE(this->chip_.status_has_warning()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertInvalidatesDuringRecoveryAndRepublishes) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + EXPECT_TRUE(this->chip_.status_has_warning()); + + this->chip_.update(); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + run_timers(45); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_TRUE(this->alert_.state); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -170,13 +248,16 @@ TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { } TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->chip_.set_alert_binary_sensor(&this->alert_); this->configure(0x0180); this->chip_.setup(); + this->alert_.publish_state(false); this->bus_.fail_write = 1; this->chip_.update(); run_timers(45); EXPECT_EQ(this->publications_, 0); EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); } TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) {