diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index bf3c6c34c83ee0276b1eb5d96f6da892963903b8..11d99a1558a9a12d176eee6ce2ac5b8eb21ddb94 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import IronOSCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] type IronOSConfigEntry = ConfigEntry[IronOSCoordinator] diff --git a/homeassistant/components/iron_os/const.py b/homeassistant/components/iron_os/const.py index 86b7d401f4fce5168bab1fb59a5fbdca484f1bfb..34889636808dcb9dada89c3fb592236d61c5d28e 100644 --- a/homeassistant/components/iron_os/const.py +++ b/homeassistant/components/iron_os/const.py @@ -8,3 +8,6 @@ MODEL = "Pinecil V2" OHM = "Ω" DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533" + +MAX_TEMP: int = 450 +MIN_TEMP: int = 10 diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 0d207607a4f5291312080c591142260461e95426..fa14b8134d0a37c350e953dfea978ee1847bc232 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -1,12 +1,14 @@ { "entity": { + "number": { + "setpoint_temperature": { + "default": "mdi:thermometer" + } + }, "sensor": { "live_temperature": { "default": "mdi:soldering-iron" }, - "setpoint_temperature": { - "default": "mdi:thermostat" - }, "voltage": { "default": "mdi:current-dc" }, diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py new file mode 100644 index 0000000000000000000000000000000000000000..9230faec1f13ff761ed989abdae2aef544eb4fb1 --- /dev/null +++ b/homeassistant/components/iron_os/number.py @@ -0,0 +1,96 @@ +"""Number platform for IronOS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pynecil import CharSetting, CommunicationError, LiveDataResponse + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .const import DOMAIN, MAX_TEMP, MIN_TEMP +from .entity import IronOSBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IronOSNumberEntityDescription(NumberEntityDescription): + """Describes IronOS number entity.""" + + value_fn: Callable[[LiveDataResponse], float | int | None] + max_value_fn: Callable[[LiveDataResponse], float | int] + set_key: CharSetting + + +class PinecilNumber(StrEnum): + """Number controls for Pinecil device.""" + + SETPOINT_TEMP = "setpoint_temperature" + + +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, + set_key=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), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + IronOSNumberEntity(coordinator, description) + for description in PINECIL_NUMBER_DESCRIPTIONS + ) + + +class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): + """Implementation of a IronOS number entity.""" + + entity_description: IronOSNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + try: + await self.coordinator.device.write(self.entity_description.set_key, value) + except CommunicationError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="submit_setting_failed", + ) from e + self.async_write_ha_state() + + @property + def native_value(self) -> float | int | None: + """Return sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) + + @property + def native_max_value(self) -> float: + """Return sensor state.""" + return self.entity_description.max_value_fn(self.coordinator.data) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index cb95330b7686a33cf3eb3f318d189705016f6f96..75584fe191c37019e1c0827f70718b2a6e397485 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -17,6 +17,11 @@ } }, "entity": { + "number": { + "setpoint_temperature": { + "name": "Setpoint temperature" + } + }, "sensor": { "live_temperature": { "name": "Tip temperature" @@ -79,6 +84,9 @@ }, "setup_device_connection_error_exception": { "message": "Connection to device {name} failed, try again later" + }, + "submit_setting_failed": { + "message": "Failed to submit setting to device, try again later" } } } diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr new file mode 100644 index 0000000000000000000000000000000000000000..2f5ee62e37e1b705557331647f2397c19edf4cc5 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_state[number.pinecil_setpoint_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 450, + 'min': 10, + 'mode': <NumberMode.BOX: 'box'>, + 'step': 5, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pinecil_setpoint_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>, + 'original_icon': None, + 'original_name': 'Setpoint temperature', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': <PinecilNumber.SETPOINT_TEMP: 'setpoint_temperature'>, + 'unique_id': 'c0:ff:ee:c0:ff:ee_setpoint_temperature', + 'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>, + }) +# --- +# name: test_state[number.pinecil_setpoint_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pinecil Setpoint temperature', + 'max': 450, + 'min': 10, + 'mode': <NumberMode.BOX: 'box'>, + 'step': 5, + 'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>, + }), + 'context': <ANY>, + 'entity_id': 'number.pinecil_setpoint_temperature', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '300', + }) +# --- diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py new file mode 100644 index 0000000000000000000000000000000000000000..c091040668c300ba5b5312d285c473d3b05954f0 --- /dev/null +++ b/tests/components/iron_os/test_number.py @@ -0,0 +1,104 @@ +"""Tests for the IronOS number platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from pynecil import CharSetting, CommunicationError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def sensor_only() -> AsyncGenerator[None, None]: + """Enable only the number platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.NUMBER], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the IronOS number platform states.""" + 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 + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_set_value( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the IronOS number platform set value service.""" + + 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 + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 300}, + target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(CharSetting.SETPOINT_TEMP, 300) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_set_value_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the IronOS number platform set value service with exception.""" + + 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 + + mock_pynecil.write.side_effect = CommunicationError + + with pytest.raises( + ServiceValidationError, + match="Failed to submit setting to device, try again later", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 300}, + target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, + blocking=True, + )