diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py index e938c56b9ee..fcacc851dd9 100644 --- a/homeassistant/components/togrill/__init__.py +++ b/homeassistant/components/togrill/__init__.py @@ -8,7 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 6aa06260178..b2f20963fc8 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -4,11 +4,17 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TypeVar from bleak.exc import BleakError from togrill_bluetooth.client import Client from togrill_bluetooth.exceptions import DecodeError -from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify +from togrill_bluetooth.packets import ( + Packet, + PacketA0Notify, + PacketA1Notify, + PacketA8Write, +) from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,11 +31,15 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import CONF_PROBE_COUNT + type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] SCAN_INTERVAL = timedelta(seconds=30) LOGGER = logging.getLogger(__name__) +PacketType = TypeVar("PacketType", bound=Packet) + def get_version_string(packet: PacketA0Notify) -> str: """Construct a version string from packet data.""" @@ -44,7 +54,7 @@ class DeviceFailed(UpdateFailed): """Update failed due to device disconnected.""" -class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): +class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Packet]]): """Class to manage fetching data.""" config_entry: ToGrillConfigEntry @@ -86,7 +96,12 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): if not device: raise DeviceNotFound("Unable to find device") - client = await Client.connect(device, self._notify_callback) + try: + client = await Client.connect(device, self._notify_callback) + except BleakError as exc: + self.logger.debug("Connection failed", exc_info=True) + raise DeviceNotFound("Unable to connect to device") from exc + try: packet_a0 = await client.read(PacketA0Notify) except (BleakError, DecodeError) as exc: @@ -123,16 +138,29 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): self.client = await self._connect_and_update_registry() return self.client + def get_packet( + self, packet_type: type[PacketType], probe=None + ) -> PacketType | None: + """Get a cached packet of a certain type.""" + + if packet := self.data.get((packet_type.type, probe)): + assert isinstance(packet, packet_type) + return packet + return None + def _notify_callback(self, packet: Packet): - self.data[packet.type] = packet + probe = getattr(packet, "probe", None) + self.data[(packet.type, probe)] = packet self.async_update_listeners() - async def _async_update_data(self) -> dict[int, Packet]: + async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: """Poll the device.""" client = await self._get_connected_client() try: await client.request(PacketA0Notify) await client.request(PacketA1Notify) + for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1): + await client.write(PacketA8Write(probe=probe)) except BleakError as exc: raise DeviceFailed(f"Device failed {exc}") from exc return self.data diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py index c1a254557c5..7d956ac2d57 100644 --- a/homeassistant/components/togrill/entity.py +++ b/homeassistant/components/togrill/entity.py @@ -2,9 +2,16 @@ from __future__ import annotations +from bleak.exc import BleakError +from togrill_bluetooth.client import Client +from togrill_bluetooth.exceptions import BaseError +from togrill_bluetooth.packets import PacketWrite + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .coordinator import ToGrillCoordinator +from .const import DOMAIN +from .coordinator import LOGGER, ToGrillCoordinator class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): @@ -16,3 +23,27 @@ class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): """Initialize coordinator entity.""" super().__init__(coordinator) self._attr_device_info = coordinator.device_info + + def _get_client(self) -> Client: + client = self.coordinator.client + if client is None or not client.is_connected: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="disconnected" + ) + return client + + async def _write_packet(self, packet: PacketWrite) -> None: + client = self._get_client() + try: + await client.write(packet) + except BleakError as exc: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="communication_failed" + ) from exc + except BaseError as exc: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="rejected" + ) from exc + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json index 9f9ad8c3782..4b833aec4ee 100644 --- a/homeassistant/components/togrill/manifest.json +++ b/homeassistant/components/togrill/manifest.json @@ -13,6 +13,7 @@ "dependencies": ["bluetooth"], "documentation": "https://www.home-assistant.io/integrations/togrill", "iot_class": "local_push", + "loggers": ["togrill_bluetooth"], "quality_scale": "bronze", "requirements": ["togrill-bluetooth==0.7.0"] } diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py new file mode 100644 index 00000000000..a87fec8d2d3 --- /dev/null +++ b/homeassistant/components/togrill/number.py @@ -0,0 +1,138 @@ +"""Support for number entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from togrill_bluetooth.packets import ( + PacketA0Notify, + PacketA6Write, + PacketA8Notify, + PacketA301Write, + PacketWrite, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ToGrillConfigEntry +from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ToGrillNumberEntityDescription(NumberEntityDescription): + """Description of entity.""" + + get_value: Callable[[ToGrillCoordinator], float | None] + set_packet: Callable[[float], PacketWrite] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_target_description( + probe_number: int, +) -> ToGrillNumberEntityDescription: + def _set_packet(value: float | None) -> PacketWrite: + if value == 0.0: + value = None + return PacketA301Write(probe=probe_number, target=value) + + def _get_value(coordinator: ToGrillCoordinator) -> float | None: + if packet := coordinator.get_packet(PacketA8Notify, probe_number): + if packet.alarm_type == PacketA8Notify.AlarmType.TEMPERATURE_TARGET: + return packet.temperature_1 + return None + + return ToGrillNumberEntityDescription( + key=f"temperature_target_{probe_number}", + translation_key="temperature_target", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=0, + native_max_value=250, + mode=NumberMode.BOX, + set_packet=_set_packet, + get_value=_get_value, + entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT], + ) + + +ENTITY_DESCRIPTIONS = ( + *[ + _get_temperature_target_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], + ToGrillNumberEntityDescription( + key="alarm_interval", + translation_key="alarm_interval", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_min_value=0, + native_max_value=15, + native_step=5, + mode=NumberMode.BOX, + set_packet=lambda x: ( + PacketA6Write(temperature_unit=None, alarm_interval=round(x)) + ), + get_value=lambda x: ( + packet.alarm_interval if (packet := x.get_packet(PacketA0Notify)) else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillNumber(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillNumber(ToGrillEntity, NumberEntity): + """Representation of a number.""" + + entity_description: ToGrillNumberEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillNumberEntityDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self.entity_description.get_value(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set value on device.""" + + packet = self.entity_description.set_packet(value) + await self._write_packet(packet) diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py index 7298e4b971b..1641236bfc1 100644 --- a/homeassistant/components/togrill/sensor.py +++ b/homeassistant/components/togrill/sensor.py @@ -122,6 +122,8 @@ class ToGrillSensor(ToGrillEntity, SensorEntity): @property def native_value(self) -> StateType: """Get current value.""" - if packet := self.coordinator.data.get(self.entity_description.packet_type): + if packet := self.coordinator.data.get( + (self.entity_description.packet_type, None) + ): return self.entity_description.packet_extract(packet) return None diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json index 1b75e387221..a49b6613d3c 100644 --- a/homeassistant/components/togrill/strings.json +++ b/homeassistant/components/togrill/strings.json @@ -22,11 +22,30 @@ "failed_to_read_config": "Failed to read config from device" } }, + "exceptions": { + "disconnected": { + "message": "The device is disconnected" + }, + "communication_failed": { + "message": "Communication failed with the device" + }, + "rejected": { + "message": "Data was rejected by device" + } + }, "entity": { "sensor": { "temperature": { "name": "Probe {probe_number}" } + }, + "number": { + "temperature_target": { + "name": "Target {probe_number}" + }, + "alarm_interval": { + "name": "Alarm interval" + } } } } diff --git a/tests/components/togrill/snapshots/test_number.ambr b/tests/components/togrill/snapshots/test_number.ambr new file mode 100644 index 00000000000..639f2758c69 --- /dev/null +++ b/tests/components/togrill/snapshots/test_number.ambr @@ -0,0 +1,355 @@ +# serializer version: 1 +# name: test_setup[no_data][number.pro_05_alarm_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_alarm_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm interval', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_interval', + 'unique_id': '00000000-0000-0000-0000-000000000001_alarm_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_alarm_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pro-05 Alarm interval', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_alarm_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][number.pro_05_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 1', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.pro_05_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 2', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_alarm_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm interval', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_interval', + 'unique_id': '00000000-0000-0000-0000-000000000001_alarm_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pro-05 Alarm interval', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_alarm_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 1', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 2', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/togrill/test_number.py b/tests/components/togrill/test_number.py new file mode 100644 index 00000000000..05ef6b49d07 --- /dev/null +++ b/tests/components/togrill/test_number.py @@ -0,0 +1,243 @@ +"""Test numbers for ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.exceptions import BaseError +from togrill_bluetooth.packets import ( + PacketA0Notify, + PacketA6Write, + PacketA8Notify, + PacketA301Write, +) + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ), + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + PacketA8Notify(probe=2, alarm_type=None), + ], + id="one_probe_with_target_alarm", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the numbers.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("packets", "entity_id", "value", "write_packet"), + [ + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.pro_05_target_1", + 100.0, + PacketA301Write(probe=1, target=100), + id="probe", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.pro_05_target_1", + 0.0, + PacketA301Write(probe=1, target=None), + id="probe_clear", + ), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ) + ], + "number.pro_05_alarm_interval", + 15, + PacketA6Write(temperature_unit=None, alarm_interval=15), + id="alarm_interval", + ), + ], +) +async def test_set_number( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, + entity_id, + value, + write_packet, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: value, + }, + target={ + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + mock_client.write.assert_any_call(write_packet) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + pytest.param( + BleakError("Some error"), + "Communication failed with the device", + id="bleak", + ), + pytest.param( + BaseError("Some error"), + "Data was rejected by device", + id="base", + ), + ], +) +async def test_set_number_write_error( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + error, + message, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ) + mock_client.write.side_effect = error + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.pro_05_target_1", + }, + blocking=True, + ) + + +async def test_set_number_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ) + mock_client.is_connected = False + + with pytest.raises(HomeAssistantError, match=""): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.pro_05_target_1", + }, + blocking=True, + )