diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 077dd03ccc..9b4cf6680a 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,10 +1,14 @@ """Shared TMP102 types and configuration validation.""" +from dataclasses import dataclass, field +import math + 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 +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -16,6 +20,7 @@ tmp102_ns = cg.esphome_ns.namespace("tmp102") TMP102Component = tmp102_ns.class_( "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor ) +TMP102LimitType = tmp102_ns.enum("TMP102LimitType") CONF_EXTENDED_MODE = "extended_mode" CONF_ONE_SHOT_MODE = "one_shot_mode" @@ -34,10 +39,40 @@ TMP102_CONVERSION_FACTOR = 0.0625 TMP102_DEFAULT_THIGH = 80.0 TMP102_DEFAULT_TLOW = 75.0 +TMP102_MIN_TEMPERATURE = -55.0 +TMP102_MAX_TEMPERATURE = 127.9375 +TMP102_MAX_TEMPERATURE_EXTENDED = 150.0 + + +def max_temperature(extended: bool) -> float: + return TMP102_MAX_TEMPERATURE_EXTENDED if extended else TMP102_MAX_TEMPERATURE + + +@dataclass +class TMP102Data: + # Extended mode of each sensor that has threshold numbers, keyed by sensor ID. + number_parents: dict[str, bool] = field(default_factory=dict) + + +def _get_data() -> TMP102Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TMP102Data() + return CORE.data[DOMAIN] + + +def add_number_parent(parent_id: object, extended: bool) -> None: + _get_data().number_parents[str(parent_id)] = extended + + +def number_parent_extended(parent_id: object) -> bool | None: + """Return the extended mode of a sensor with threshold numbers, or None without numbers.""" + return _get_data().number_parents.get(str(parent_id)) + def encode_temperature(temperature: float, extended: bool) -> int: """Encode a temperature as a TMP102 limit-register word.""" - steps = round(temperature / TMP102_CONVERSION_FACTOR) + scaled = temperature / TMP102_CONVERSION_FACTOR + steps = int(scaled + math.copysign(0.5, scaled)) return (steps << (3 if extended else 4)) & 0xFFFF @@ -56,8 +91,8 @@ def build_configuration(config: ConfigType) -> int: def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: extended = config.get(CONF_EXTENDED_MODE, False) - t_min = -55.0 - t_max = 150.0 if extended else 127.9375 + t_min = TMP102_MIN_TEMPERATURE + t_max = max_temperature(extended) high = config.get(CONF_TEMPERATURE_HIGH) low = config.get(CONF_TEMPERATURE_LOW) for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): diff --git a/esphome/components/tmp102/number.py b/esphome/components/tmp102/number.py new file mode 100644 index 0000000000..e353a8d118 --- /dev/null +++ b/esphome/components/tmp102/number.py @@ -0,0 +1,87 @@ +"""Adjustable TMP102 thermostat thresholds.""" + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_RESTORE_VALUE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_CONFIG, + UNIT_CELSIUS, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( + CONF_EXTENDED_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_TMP102_ID, + TMP102_CONVERSION_FACTOR, + TMP102_MIN_TEMPERATURE, + TMP102Component, + TMP102LimitType, + add_number_parent, + max_temperature, + number_parent_extended, + tmp102_ns, + validate_unique_child, +) + +TMP102LimitNumber = tmp102_ns.class_( + "TMP102LimitNumber", number.Number, cg.Parented.template(TMP102Component) +) + +_LIMIT_SCHEMA = number.number_schema( + TMP102LimitNumber, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend({cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean}) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + cv.Optional(CONF_TEMPERATURE_HIGH): _LIMIT_SCHEMA, + cv.Optional(CONF_TEMPERATURE_LOW): _LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE_HIGH, CONF_TEMPERATURE_LOW), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + validate_unique_child(config, "number") + full = fv.full_config.get() + parent = full.get_config_for_path(full.get_path_for_id(config[CONF_TMP102_ID])[:-1]) + add_number_parent(config[CONF_TMP102_ID], parent.get(CONF_EXTENDED_MODE, False)) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_NUMBER") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + limits = { + "min_value": TMP102_MIN_TEMPERATURE, + "max_value": max_temperature(number_parent_extended(config[CONF_TMP102_ID])), + "step": TMP102_CONVERSION_FACTOR, + } + numbers = number.sub_numbers(config, parent=parent) + if var := await numbers( + CONF_TEMPERATURE_HIGH, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_HIGH, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_HIGH][CONF_RESTORE_VALUE])) + if var := await numbers( + CONF_TEMPERATURE_LOW, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_LOW, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_LOW][CONF_RESTORE_VALUE])) diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index fbfed7444d..c1eda11815 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -5,6 +5,7 @@ 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_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -28,6 +29,7 @@ from . import ( TMP102Component, build_configuration, encode_temperature, + number_parent_extended, validate_tmp102_thresholds, ) @@ -66,17 +68,20 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if not any(key in config for key in _ADVANCED_SCHEMA): + has_numbers = number_parent_extended(config[CONF_ID]) is not None + if not has_numbers and not any(key in config for key in _ADVANCED_SCHEMA): return extended = config.get(CONF_EXTENDED_MODE, False) - high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) - low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) cg.add_define("USE_TMP102_CONFIGURE") cg.add( var.set_configuration( build_configuration(config), - encode_temperature(high, extended), - encode_temperature(low, extended), + encode_temperature( + config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH), extended + ), + encode_temperature( + config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW), extended + ), ) ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 09b29dd7ef..bb82975dbc 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + namespace esphome::tmp102 { ESPHOME_LOG_TAG(TAG, "tmp102"); @@ -18,6 +20,9 @@ static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; #ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +#ifdef USE_TMP102_NUMBER +static_assert(TMP102_REGISTER_LOW_LIMIT + TMP102_LIMIT_HIGH == TMP102_REGISTER_HIGH_LIMIT); +#endif static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; @@ -31,11 +36,39 @@ static float decode_temperature(uint16_t raw, bool extended) { return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; } +#ifdef USE_TMP102_NUMBER +static uint16_t encode_temperature(float temperature, bool extended) { + const int16_t steps = static_cast(std::lround(temperature / TMP102_CONVERSION_FACTOR)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); +} +#endif + void TMP102Component::setup() { if (!this->configure_) return; - if (!this->write_configuration_()) +#ifdef USE_TMP102_NUMBER + float restored[2]{}; + this->prepare_number_limits_(restored); +#endif + if (!this->write_configuration_()) { this->mark_failed(); + return; + } +#ifdef USE_TMP102_NUMBER + bool saved = true; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + const float value = this->get_limit_temperature_(static_cast(limit)); + if (value != restored[limit]) + saved &= control->save_value_(value); + control->publish_state(value); + } + if (!saved) { + ESP_LOGW(TAG, "Failed to save threshold preferences"); + } +#endif } #endif @@ -75,6 +108,10 @@ void TMP102Component::dump_config() { this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), decode_temperature(this->low_limit_, extended)); +#ifdef USE_TMP102_NUMBER + LOG_NUMBER(" ", "High Limit", this->limit_controls_[TMP102_LIMIT_HIGH]); + LOG_NUMBER(" ", "Low Limit", this->limit_controls_[TMP102_LIMIT_LOW]); +#endif if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); } @@ -185,6 +222,55 @@ void TMP102Component::read_temperature_() { #endif this->status_clear_warning(); } + +#ifdef USE_TMP102_NUMBER +void TMP102Component::set_limit_control(TMP102LimitNumber *number) { + this->limit_controls_[number->limit_type_] = number; +} + +void TMP102Component::prepare_number_limits_(float *restored) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + raw[limit] = encode_temperature(restored[limit], extended); + } + if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { + ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); + return; + } + this->low_limit_ = raw[TMP102_LIMIT_LOW]; + this->high_limit_ = raw[TMP102_LIMIT_HIGH]; +} + +float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { + const uint16_t raw = limit == TMP102_LIMIT_HIGH ? this->high_limit_ : this->low_limit_; + return decode_temperature(raw, this->config_ & TMP102_EXTENDED_MODE_BIT); +} + +bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temperature) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + const uint16_t raw = encode_temperature(temperature, extended); + const bool high = limit == TMP102_LIMIT_HIGH; + const uint16_t other = high ? this->low_limit_ : this->high_limit_; + if (high ? static_cast(raw) < static_cast(other) + : static_cast(raw) > static_cast(other)) { + ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", + high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), + high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + return false; + } + if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { + this->status_set_warning(); + return false; + } + (high ? this->high_limit_ : this->low_limit_) = raw; + return true; +} +#endif #endif #ifdef USE_TMP102_BINARY_SENSOR @@ -207,4 +293,35 @@ bool TMP102Component::read_alert_state_() { } #endif +#ifdef USE_TMP102_NUMBER +float TMP102LimitNumber::load_value_(float fallback) { + float value = fallback; + if (this->restore_value_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&value); + } + if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { + ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); + value = fallback; + } + return value; +} + +bool TMP102LimitNumber::save_value_(float value) { return !this->restore_value_ || this->pref_.save(&value); } + +void TMP102LimitNumber::control(float value) { + // A chip that failed setup holds nothing the entity could show + if (this->parent_->is_failed()) { + ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + return; + } + const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); + value = this->parent_->get_limit_temperature_(this->limit_type_); + this->publish_state(value); + if (accepted && !this->save_value_(value)) { + ESP_LOGW(TAG, "Failed to save threshold preference"); + } +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index caf75e19be..d88dde7834 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,12 +6,26 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif #include "esphome/components/i2c/i2c.h" +#ifdef USE_TMP102_NUMBER +#include "esphome/components/number/number.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#endif #include "esphome/components/sensor/sensor.h" #include namespace esphome::tmp102 { +#ifdef USE_TMP102_NUMBER +enum TMP102LimitType : uint8_t { + TMP102_LIMIT_LOW = 0, + TMP102_LIMIT_HIGH = 1, +}; + +class TMP102LimitNumber; +#endif + class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: #ifdef USE_TMP102_BINARY_SENSOR @@ -25,6 +39,9 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu this->low_limit_ = low_limit; this->configure_ = true; } +#ifdef USE_TMP102_NUMBER + void set_limit_control(TMP102LimitNumber *number); +#endif #endif void dump_config() override; void update() override; @@ -46,6 +63,14 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu bool check_configuration_(); void read_temperature_(); void update_configured_(); +#ifdef USE_TMP102_NUMBER + friend class TMP102LimitNumber; + bool set_limit_temperature_(TMP102LimitType limit, float temperature); + float get_limit_temperature_(TMP102LimitType limit) const; + void prepare_number_limits_(float *restored); + + TMP102LimitNumber *limit_controls_[2]{}; +#endif uint16_t config_{0}; uint16_t high_limit_{0}; @@ -54,4 +79,22 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #endif }; +#ifdef USE_TMP102_NUMBER +class TMP102LimitNumber final : public number::Number, public Parented { + public: + explicit TMP102LimitNumber(TMP102LimitType limit_type) : limit_type_(limit_type) {} + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + friend class TMP102Component; + float load_value_(float fallback); + bool save_value_(float value); + void control(float value) override; + + ESPPreferenceObject pref_; + TMP102LimitType limit_type_; + bool restore_value_{true}; +}; +#endif + } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6f2a499730..fc6dd82ed9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_TIME_TRIGGERS #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE +#define USE_TMP102_NUMBER #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/config/duplicate_numbers.yaml b/tests/component_tests/tmp102/config/duplicate_numbers.yaml new file mode 100644 index 0000000000..04f634741f --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_numbers.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High One + - platform: tmp102 + tmp102_id: chip + temperature_low: + name: Low Two diff --git a/tests/component_tests/tmp102/config/numbers.yaml b/tests/component_tests/tmp102/config/numbers.yaml new file mode 100644 index 0000000000..272bd0dfe6 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers.yaml @@ -0,0 +1,24 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + temperature_low: + name: Low + restore_value: false diff --git a/tests/component_tests/tmp102/config/numbers_inherit.yaml b/tests/component_tests/tmp102/config/numbers_inherit.yaml new file mode 100644 index 0000000000..cbd4280f03 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_inherit.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: true + temperature_high: 140 + temperature_low: 120 + +number: + - platform: tmp102 + temperature_high: + name: High + temperature_low: + name: Low diff --git a/tests/component_tests/tmp102/config/numbers_initial_value.yaml b/tests/component_tests/tmp102/config/numbers_initial_value.yaml new file mode 100644 index 0000000000..60d2557701 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_initial_value.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + initial_value: 30 diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 49f2629baf..b933db1d9b 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -58,6 +58,54 @@ def test_duplicate_alert_entities_are_rejected( ) +def test_threshold_numbers_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(128, 20480, 19200)" in source + assert source.count("set_limit_control(") == 2 + assert "tmp102::TMP102_LIMIT_HIGH" in source + assert "tmp102::TMP102_LIMIT_LOW" in source + assert "set_restore_value(false)" in source + assert "set_max_value(127.9375f)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_NUMBER" in defines + assert "USE_TMP102_CONFIGURE" in defines + + +def test_threshold_numbers_use_sensor_thresholds( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers_inherit.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(144, 17920, 15360)" in source + assert "set_max_value(150.0f)" in source + + +def test_threshold_number_initial_value_is_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("numbers_initial_value.yaml") + assert read_config({}) is None + assert "[initial_value] is an invalid option" in capsys.readouterr().out + + +def test_duplicate_numbers_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_numbers.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 number may reference each temperature sensor" + in capsys.readouterr().out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ @@ -117,5 +165,12 @@ def test_precomputed_register_values() -> None: assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 +def test_threshold_encoding_rounds_half_steps_away_from_zero() -> None: + assert component.encode_temperature(30.03125, False) == 481 << 4 + assert component.encode_temperature(-30.03125, False) == (-481 << 4) & 0xFFFF + assert component.encode_temperature(30.03125, True) == 481 << 3 + assert component.encode_temperature(-30.03125, True) == (-481 << 3) & 0xFFFF + + def test_defaults_match_chip_reset() -> None: assert component.build_configuration({}) == 0x0080 diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index f86177c0f9..346efac83e 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,10 +6,16 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - for platform in ("sensor", "binary_sensor"): + for platform in ("sensor", "binary_sensor", "number"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") + cg.add_define("USE_TMP102_NUMBER") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] + manifest.dependencies = manifest.dependencies + [ + "i2c", + "sensor", + "binary_sensor", + "number", + ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 1e5f1f456c..dfc2e2e224 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -16,6 +16,16 @@ sensor: temperature_high: 140°C temperature_low: 120°C +number: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + temperature_high: + name: TMP102 High Threshold + restore_value: true + temperature_low: + name: TMP102 Low Threshold + restore_value: false + binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index cd06d8a83d..6c294f4cdc 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -3,12 +3,23 @@ #include #include +#include +#include +#include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" namespace esphome::tmp102::testing { +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + class RegisterBus final : public i2c::I2CBus { public: std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; @@ -48,21 +59,32 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; + TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; void SetUp() override { new (&App) Application; - char name[] = "tmp102-regression-tests"; - char friendly_name[] = "TMP102 tests"; - App.pre_setup(name, 23, friendly_name, 12); + App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); + host::setup_preferences(); + host::host_preferences->reset(); this->chip_.set_i2c_bus(&this->bus_); this->chip_.set_i2c_address(0x48); this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + EntityIdentity::configure(this->high_, "High", 0x102001); + EntityIdentity::configure(this->low_, "Low", 0x102002); + for (auto *number : {&this->high_, &this->low_}) { + number->set_parent(&this->chip_); + number->traits.set_min_value(-55); + number->traits.set_max_value(127.9375f); + number->traits.set_step(0.0625f); + } } void TearDown() override { App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); App.scheduler.call(millis()); + host::host_preferences->reset(); App.~Application(); } @@ -75,6 +97,31 @@ class TMP102Test : public ::testing::Test { void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { this->chip_.set_configuration(config, high, low); } + + static uint16_t encode(float temperature, bool extended = false) { + const int16_t steps = static_cast(std::lround(temperature / 0.0625f)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); + } + + void numbers_(float initial_high = 30, float initial_low = 25, bool extended = false) { + this->configure(extended ? 0x0090 : 0x0080, encode(initial_high, extended), encode(initial_low, extended)); + if (extended) { + this->high_.traits.set_max_value(150); + this->low_.traits.set_max_value(150); + } + this->chip_.set_limit_control(&this->high_); + this->chip_.set_limit_control(&this->low_); + } + + static float saved(TMP102LimitNumber &number) { + float value = NAN; + EXPECT_TRUE(number.make_entity_preference().load(&value)); + return value; + } + + static void save(TMP102LimitNumber &number, float value) { + ASSERT_TRUE(number.make_entity_preference().save(&value)); + } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -190,6 +237,62 @@ TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { EXPECT_FALSE(this->chip_.status_has_warning()); } +TEST_F(TMP102Test, QuantizedRuntimeValuesMatchRegistersAndPreferences) { + this->numbers_((10.0f - 32) * 5 / 9, (9.0f - 32) * 5 / 9); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, -12.25f); + EXPECT_FLOAT_EQ(this->low_.state, -12.75f); + EXPECT_EQ(this->bus_.registers[3], 0xF3C0); + for (auto [requested, expected] : + {std::pair{12.22f, 12.25f}, {-12.22f, -12.25f}, {12.03125f, 12.0625f}, {-12.03125f, -12.0625f}}) { + this->high_.make_call().set_value(requested).perform(); + EXPECT_FLOAT_EQ(this->high_.state, expected); + EXPECT_FLOAT_EQ(saved(this->high_), expected); + EXPECT_FLOAT_EQ(static_cast(this->bus_.registers[3]) / 256.0f, expected); + } +} + +TEST_F(TMP102Test, ExtendedModeRuntimeValueMatchesRegister) { + this->numbers_(140, 120, true); + this->chip_.setup(); + this->high_.make_call().set_value(145.0625f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 145.0625f); + EXPECT_EQ(this->bus_.registers[3], encode(145.0625f, true)); + EXPECT_FLOAT_EQ(saved(this->high_), 145.0625f); +} + +TEST_F(TMP102Test, HighOnlyInvalidRestoreFallsBackAgainstStaticLow) { + this->configure(0x0080, encode(30), encode(25)); + this->chip_.set_limit_control(&this->high_); + save(this->high_, 20); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_EQ(this->bus_.registers[3], encode(30)); + EXPECT_EQ(this->bus_.registers[2], encode(25)); + EXPECT_FLOAT_EQ(saved(this->high_), 30); +} + +TEST_F(TMP102Test, OrderingUsesAcceptedQuantizedThreshold) { + this->numbers_(30.02f, 30.01f); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 30); + this->high_.make_call().set_value(29.96f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(29.99f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); +} + +TEST_F(TMP102Test, FailedThresholdWriteWarningClearsAfterSuccessfulRead) { + this->numbers_(); + this->chip_.setup(); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(90).perform(); + EXPECT_TRUE(this->chip_.status_has_warning()); + this->chip_.update(); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -304,6 +407,87 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } } +TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); +} + +TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->numbers_(); + save(this->high_, NAN); + save(this->low_, INFINITY); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); +} + +TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->bus_.fail_write = 3; + this->chip_.setup(); + EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_FLOAT_EQ(saved(this->low_), 85); +} + +TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->numbers_(); + save(this->high_, 90); + this->bus_.fail_write = 3; + this->chip_.setup(); + ASSERT_TRUE(this->chip_.is_failed()); + const unsigned writes = this->bus_.writes; + this->high_.make_call().set_value(60).perform(); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { + this->numbers_(); + save(this->high_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->bus_.registers[3], encode(35)); + float value; + EXPECT_FALSE(this->low_.make_entity_preference().load(&value)); +} + +TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->chip_.setup(); + unsigned publications = 0; + this->high_.add_on_state_callback([&publications](float) { ++publications; }); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(100).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 90); + EXPECT_EQ(publications, 1); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { + this->numbers_(); + save(this->high_, 90); + this->high_.set_restore_value(false); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(40).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 40); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + } // namespace esphome::tmp102::testing #endif