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
This commit is contained in:
Manu 2025-06-30 10:44:39 +02:00 committed by GitHub
parent c17ee0d123
commit c7603b39ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 214 additions and 88 deletions

View File

@ -10,4 +10,8 @@ OHM = "Ω"
DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533" DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533"
MAX_TEMP: int = 450 MAX_TEMP: int = 450
MAX_TEMP_F: int = 850
MIN_TEMP: int = 10 MIN_TEMP: int = 10
MIN_TEMP_F: int = 50
MIN_BOOST_TEMP: int = 250
MIN_BOOST_TEMP_F: int = 480

View File

@ -168,7 +168,9 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
if self.device.is_connected and characteristics: if self.device.is_connected and characteristics:
try: try:
return await self.device.get_settings(list(characteristics)) return await self.device.get_settings(
list(characteristics | {CharSetting.TEMP_UNIT})
)
except CommunicationError as e: except CommunicationError as e:
_LOGGER.debug("Failed to fetch settings", exc_info=e) _LOGGER.debug("Failed to fetch settings", exc_info=e)

View File

@ -6,10 +6,9 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit
from homeassistant.components.number import ( from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
NumberDeviceClass, NumberDeviceClass,
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
@ -24,9 +23,17 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from . import IronOSConfigEntry 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 .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity from .entity import IronOSBaseEntity
@ -38,9 +45,10 @@ class IronOSNumberEntityDescription(NumberEntityDescription):
"""Describes IronOS number entity.""" """Describes IronOS number entity."""
value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None] value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None]
max_value_fn: Callable[[LiveDataResponse], float | int] | None = None
characteristic: CharSetting characteristic: CharSetting
raw_value_fn: Callable[[float], float | int] | None = None 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): class PinecilNumber(StrEnum):
@ -74,44 +82,6 @@ def multiply(value: float | None, multiplier: float) -> float | None:
PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( 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( IronOSNumberEntityDescription(
key=PinecilNumber.QC_MAX_VOLTAGE, key=PinecilNumber.QC_MAX_VOLTAGE,
translation_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_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, 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, ...] = ( 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -354,9 +374,18 @@ async def async_setup_entry(
if coordinators.live_data.v223_features: if coordinators.live_data.v223_features:
descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223
async_add_entities( entities = [
IronOSNumberEntity(coordinators, description) for description in descriptions 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): class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
@ -388,15 +417,6 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
self.coordinator.data, self.settings.data 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: async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.""" """Run when entity about to be added to hass."""
@ -407,3 +427,60 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
) )
) )
await self.settings.async_request_refresh() 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
)

View File

@ -6,7 +6,7 @@
'area_id': None, 'area_id': None,
'capabilities': dict({ 'capabilities': dict({
'max': 450, 'max': 450,
'min': 0, 'min': 250,
'mode': <NumberMode.BOX: 'box'>, 'mode': <NumberMode.BOX: 'box'>,
'step': 10, 'step': 10,
}), }),
@ -27,7 +27,7 @@
'name': None, 'name': None,
'options': dict({ 'options': dict({
}), }),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>, 'original_device_class': None,
'original_icon': None, 'original_icon': None,
'original_name': 'Boost temperature', 'original_name': 'Boost temperature',
'platform': 'iron_os', 'platform': 'iron_os',
@ -42,10 +42,9 @@
# name: test_state[number.pinecil_boost_temperature-state] # name: test_state[number.pinecil_boost_temperature-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Pinecil Boost temperature', 'friendly_name': 'Pinecil Boost temperature',
'max': 450, 'max': 450,
'min': 0, 'min': 250,
'mode': <NumberMode.BOX: 'box'>, 'mode': <NumberMode.BOX: 'box'>,
'step': 10, 'step': 10,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>, 'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
@ -839,7 +838,7 @@
'name': None, 'name': None,
'options': dict({ 'options': dict({
}), }),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>, 'original_device_class': None,
'original_icon': None, 'original_icon': None,
'original_name': 'Setpoint temperature', 'original_name': 'Setpoint temperature',
'platform': 'iron_os', 'platform': 'iron_os',
@ -854,7 +853,6 @@
# name: test_state[number.pinecil_setpoint_temperature-state] # name: test_state[number.pinecil_setpoint_temperature-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Pinecil Setpoint temperature', 'friendly_name': 'Pinecil Setpoint temperature',
'max': 450, 'max': 450,
'min': 10, 'min': 10,
@ -1015,7 +1013,7 @@
'name': None, 'name': None,
'options': dict({ 'options': dict({
}), }),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>, 'original_device_class': None,
'original_icon': None, 'original_icon': None,
'original_name': 'Sleep temperature', 'original_name': 'Sleep temperature',
'platform': 'iron_os', 'platform': 'iron_os',
@ -1030,7 +1028,6 @@
# name: test_state[number.pinecil_sleep_temperature-state] # name: test_state[number.pinecil_sleep_temperature-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Pinecil Sleep temperature', 'friendly_name': 'Pinecil Sleep temperature',
'max': 450, 'max': 450,
'min': 10, 'min': 10,

View File

@ -5,10 +5,15 @@ from datetime import timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pynecil import CharSetting, CommunicationError from pynecil import CharSetting, CommunicationError, TempUnit
import pytest import pytest
from syrupy.assertion import SnapshotAssertion 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 ( from homeassistant.components.number import (
ATTR_VALUE, ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN, DOMAIN as NUMBER_DOMAIN,
@ -56,6 +61,47 @@ async def test_state(
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) 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( @pytest.mark.parametrize(
("entity_id", "characteristic", "value", "expected_value"), ("entity_id", "characteristic", "value", "expected_value"),
[ [