From c7603b39eca8b16075184275046dec06d0c5327d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:44:39 +0200 Subject: [PATCH] Fix inputs to correctly handle Fahrenheit in IronOS (#135421) * Fix inputs to correctly handle Fahrenheit in IronOS * some refactoring * add boost switch entity * Revert switch entity * refactor * remove commented code * some changes --- homeassistant/components/iron_os/const.py | 4 + .../components/iron_os/coordinator.py | 4 +- homeassistant/components/iron_os/number.py | 233 ++++++++++++------ .../iron_os/snapshots/test_number.ambr | 13 +- tests/components/iron_os/test_number.py | 48 +++- 5 files changed, 214 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/iron_os/const.py b/homeassistant/components/iron_os/const.py index 34889636808..0ed645f8f7b 100644 --- a/homeassistant/components/iron_os/const.py +++ b/homeassistant/components/iron_os/const.py @@ -10,4 +10,8 @@ OHM = "Ω" DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533" MAX_TEMP: int = 450 +MAX_TEMP_F: int = 850 MIN_TEMP: int = 10 +MIN_TEMP_F: int = 50 +MIN_BOOST_TEMP: int = 250 +MIN_BOOST_TEMP_F: int = 480 diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 99c688ea855..7214db0a12f 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -168,7 +168,9 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): if self.device.is_connected and characteristics: try: - return await self.device.get_settings(list(characteristics)) + return await self.device.get_settings( + list(characteristics | {CharSetting.TEMP_UNIT}) + ) except CommunicationError as e: _LOGGER.debug("Failed to fetch settings", exc_info=e) diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 6ad5947cb6f..9fada23a987 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -6,10 +6,9 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse +from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit from homeassistant.components.number import ( - DEFAULT_MAX_VALUE, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -24,9 +23,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter from . import IronOSConfigEntry -from .const import MAX_TEMP, MIN_TEMP +from .const import ( + MAX_TEMP, + MAX_TEMP_F, + MIN_BOOST_TEMP, + MIN_BOOST_TEMP_F, + MIN_TEMP, + MIN_TEMP_F, +) from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -38,9 +45,10 @@ class IronOSNumberEntityDescription(NumberEntityDescription): """Describes IronOS number entity.""" value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None] - max_value_fn: Callable[[LiveDataResponse], float | int] | None = None characteristic: CharSetting raw_value_fn: Callable[[float], float | int] | None = None + native_max_value_f: float | None = None + native_min_value_f: float | None = None class PinecilNumber(StrEnum): @@ -74,44 +82,6 @@ def multiply(value: float | None, multiplier: float) -> float | None: PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( - IronOSNumberEntityDescription( - key=PinecilNumber.SETPOINT_TEMP, - translation_key=PinecilNumber.SETPOINT_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda data, _: data.setpoint_temp, - characteristic=CharSetting.SETPOINT_TEMP, - mode=NumberMode.BOX, - native_min_value=MIN_TEMP, - native_step=5, - max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP), - ), - IronOSNumberEntityDescription( - key=PinecilNumber.SLEEP_TEMP, - translation_key=PinecilNumber.SLEEP_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda _, settings: settings.get("sleep_temp"), - characteristic=CharSetting.SLEEP_TEMP, - mode=NumberMode.BOX, - native_min_value=MIN_TEMP, - native_max_value=MAX_TEMP, - native_step=10, - entity_category=EntityCategory.CONFIG, - ), - IronOSNumberEntityDescription( - key=PinecilNumber.BOOST_TEMP, - translation_key=PinecilNumber.BOOST_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda _, settings: settings.get("boost_temp"), - characteristic=CharSetting.BOOST_TEMP, - mode=NumberMode.BOX, - native_min_value=0, - native_max_value=MAX_TEMP, - native_step=10, - entity_category=EntityCategory.CONFIG, - ), IronOSNumberEntityDescription( key=PinecilNumber.QC_MAX_VOLTAGE, translation_key=PinecilNumber.QC_MAX_VOLTAGE, @@ -296,32 +266,6 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), - IronOSNumberEntityDescription( - key=PinecilNumber.TEMP_INCREMENT_SHORT, - translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, - value_fn=(lambda _, settings: settings.get("temp_increment_short")), - characteristic=CharSetting.TEMP_INCREMENT_SHORT, - raw_value_fn=lambda value: value, - mode=NumberMode.BOX, - native_min_value=1, - native_max_value=50, - native_step=1, - entity_category=EntityCategory.CONFIG, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), - IronOSNumberEntityDescription( - key=PinecilNumber.TEMP_INCREMENT_LONG, - translation_key=PinecilNumber.TEMP_INCREMENT_LONG, - value_fn=(lambda _, settings: settings.get("temp_increment_long")), - characteristic=CharSetting.TEMP_INCREMENT_LONG, - raw_value_fn=lambda value: value, - mode=NumberMode.BOX, - native_min_value=5, - native_max_value=90, - native_step=5, - entity_category=EntityCategory.CONFIG, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), ) PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( @@ -341,6 +285,82 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( ), ) +""" +The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities. +These entities represent user-defined input values, not measured temperatures, and their +interpretation depends on the device's current unit configuration. Applying a device_class +results in automatic unit conversions, which introduce rounding errors due to the use of integers. +This can prevent the correct value from being set, as the input is modified during synchronization with the device. +""" +PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.SLEEP_TEMP, + translation_key=PinecilNumber.SLEEP_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda _, settings: settings.get("sleep_temp"), + characteristic=CharSetting.SLEEP_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_min_value_f=MIN_TEMP_F, + native_max_value_f=MAX_TEMP_F, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.BOOST_TEMP, + translation_key=PinecilNumber.BOOST_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda _, settings: settings.get("boost_temp"), + characteristic=CharSetting.BOOST_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_BOOST_TEMP, + native_min_value_f=MIN_BOOST_TEMP_F, + native_max_value=MAX_TEMP, + native_max_value_f=MAX_TEMP_F, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_SHORT, + translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, + value_fn=(lambda _, settings: settings.get("temp_increment_short")), + characteristic=CharSetting.TEMP_INCREMENT_SHORT, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=50, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_LONG, + translation_key=PinecilNumber.TEMP_INCREMENT_LONG, + value_fn=(lambda _, settings: settings.get("temp_increment_long")), + characteristic=CharSetting.TEMP_INCREMENT_LONG, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=5, + native_max_value=90, + native_step=5, + entity_category=EntityCategory.CONFIG, + ), +) + +PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription( + key=PinecilNumber.SETPOINT_TEMP, + translation_key=PinecilNumber.SETPOINT_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data, _: data.setpoint_temp, + characteristic=CharSetting.SETPOINT_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_min_value_f=MIN_TEMP_F, + native_max_value_f=MAX_TEMP_F, + native_step=5, +) + async def async_setup_entry( hass: HomeAssistant, @@ -354,9 +374,18 @@ async def async_setup_entry( if coordinators.live_data.v223_features: descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 - async_add_entities( + entities = [ IronOSNumberEntity(coordinators, description) for description in descriptions + ] + + entities.extend( + IronOSTemperatureNumberEntity(coordinators, description) + for description in PINECIL_TEMP_NUMBER_DESCRIPTIONS ) + entities.append( + IronOSSetpointNumberEntity(coordinators, PINECIL_SETPOINT_NUMBER_DESCRIPTION) + ) + async_add_entities(entities) class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): @@ -388,15 +417,6 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): self.coordinator.data, self.settings.data ) - @property - def native_max_value(self) -> float: - """Return sensor state.""" - - if self.entity_description.max_value_fn is not None: - return self.entity_description.max_value_fn(self.coordinator.data) - - return self.entity_description.native_max_value or DEFAULT_MAX_VALUE - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -407,3 +427,60 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): ) ) await self.settings.async_request_refresh() + + +class IronOSTemperatureNumberEntity(IronOSNumberEntity): + """Implementation of a IronOS temperature number entity.""" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + + return ( + UnitOfTemperature.FAHRENHEIT + if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT + else UnitOfTemperature.CELSIUS + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + + return ( + self.entity_description.native_min_value_f + if self.entity_description.native_min_value_f + and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT + else super().native_min_value + ) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + + return ( + self.entity_description.native_max_value_f + if self.entity_description.native_max_value_f + and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT + else super().native_max_value + ) + + +class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity): + """IronOS setpoint temperature entity.""" + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + + return ( + min( + TemperatureConverter.convert( + float(max_tip_c), + UnitOfTemperature.CELSIUS, + self.native_unit_of_measurement, + ), + super().native_max_value, + ) + if (max_tip_c := self.coordinator.data.max_tip_temp_ability) is not None + else super().native_max_value + ) diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 37d8b1f4819..52fd6bb2ce4 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -6,7 +6,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 450, - 'min': 0, + 'min': 250, 'mode': , 'step': 10, }), @@ -27,7 +27,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Boost temperature', 'platform': 'iron_os', @@ -42,10 +42,9 @@ # name: test_state[number.pinecil_boost_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Boost temperature', 'max': 450, - 'min': 0, + 'min': 250, 'mode': , 'step': 10, 'unit_of_measurement': , @@ -839,7 +838,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Setpoint temperature', 'platform': 'iron_os', @@ -854,7 +853,6 @@ # name: test_state[number.pinecil_setpoint_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Setpoint temperature', 'max': 450, 'min': 10, @@ -1015,7 +1013,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Sleep temperature', 'platform': 'iron_os', @@ -1030,7 +1028,6 @@ # name: test_state[number.pinecil_sleep_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Sleep temperature', 'max': 450, 'min': 10, diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 9a4ba53f338..3c7be52c577 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -5,10 +5,15 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CharSetting, CommunicationError +from pynecil import CharSetting, CommunicationError, TempUnit import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.iron_os.const import ( + MAX_TEMP_F, + MIN_BOOST_TEMP_F, + MIN_TEMP_F, +) from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -56,6 +61,47 @@ async def test_state( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + ("entity_id", "min_value", "max_value"), + [ + ("number.pinecil_setpoint_temperature", MIN_TEMP_F, MAX_TEMP_F), + ("number.pinecil_boost_temperature", MIN_BOOST_TEMP_F, MAX_TEMP_F), + ("number.pinecil_long_press_temperature_step", 5, 90), + ("number.pinecil_short_press_temperature_step", 1, 50), + ("number.pinecil_sleep_temperature", MIN_TEMP_F, MAX_TEMP_F), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_state_fahrenheit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_pynecil: AsyncMock, + entity_id: str, + min_value: int, + max_value: int, +) -> None: + """Test with temp unit set to fahrenheit.""" + + mock_pynecil.get_settings.return_value["temp_unit"] = TempUnit.FAHRENHEIT + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + + assert state.attributes["min"] == min_value + assert state.attributes["max"] == max_value + + @pytest.mark.parametrize( ("entity_id", "characteristic", "value", "expected_value"), [