mirror of
https://github.com/home-assistant/core.git
synced 2025-09-11 07:41:35 +02:00
Add number entity to togrill (#150609)
This commit is contained in:
@@ -8,7 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
|
|
||||||
from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator
|
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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool:
|
||||||
|
@@ -4,11 +4,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
from togrill_bluetooth.client import Client
|
from togrill_bluetooth.client import Client
|
||||||
from togrill_bluetooth.exceptions import DecodeError
|
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 import bluetooth
|
||||||
from homeassistant.components.bluetooth import (
|
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.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import CONF_PROBE_COUNT
|
||||||
|
|
||||||
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
|
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PacketType = TypeVar("PacketType", bound=Packet)
|
||||||
|
|
||||||
|
|
||||||
def get_version_string(packet: PacketA0Notify) -> str:
|
def get_version_string(packet: PacketA0Notify) -> str:
|
||||||
"""Construct a version string from packet data."""
|
"""Construct a version string from packet data."""
|
||||||
@@ -44,7 +54,7 @@ class DeviceFailed(UpdateFailed):
|
|||||||
"""Update failed due to device disconnected."""
|
"""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."""
|
"""Class to manage fetching data."""
|
||||||
|
|
||||||
config_entry: ToGrillConfigEntry
|
config_entry: ToGrillConfigEntry
|
||||||
@@ -86,7 +96,12 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
|
|||||||
if not device:
|
if not device:
|
||||||
raise DeviceNotFound("Unable to find 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:
|
try:
|
||||||
packet_a0 = await client.read(PacketA0Notify)
|
packet_a0 = await client.read(PacketA0Notify)
|
||||||
except (BleakError, DecodeError) as exc:
|
except (BleakError, DecodeError) as exc:
|
||||||
@@ -123,16 +138,29 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
|
|||||||
self.client = await self._connect_and_update_registry()
|
self.client = await self._connect_and_update_registry()
|
||||||
return self.client
|
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):
|
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()
|
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."""
|
"""Poll the device."""
|
||||||
client = await self._get_connected_client()
|
client = await self._get_connected_client()
|
||||||
try:
|
try:
|
||||||
await client.request(PacketA0Notify)
|
await client.request(PacketA0Notify)
|
||||||
await client.request(PacketA1Notify)
|
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:
|
except BleakError as exc:
|
||||||
raise DeviceFailed(f"Device failed {exc}") from exc
|
raise DeviceFailed(f"Device failed {exc}") from exc
|
||||||
return self.data
|
return self.data
|
||||||
|
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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 homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .coordinator import ToGrillCoordinator
|
from .const import DOMAIN
|
||||||
|
from .coordinator import LOGGER, ToGrillCoordinator
|
||||||
|
|
||||||
|
|
||||||
class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
|
class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
|
||||||
@@ -16,3 +23,27 @@ class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
|
|||||||
"""Initialize coordinator entity."""
|
"""Initialize coordinator entity."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._attr_device_info = coordinator.device_info
|
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()
|
||||||
|
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": ["bluetooth"],
|
"dependencies": ["bluetooth"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/togrill",
|
"documentation": "https://www.home-assistant.io/integrations/togrill",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
|
"loggers": ["togrill_bluetooth"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["togrill-bluetooth==0.7.0"]
|
"requirements": ["togrill-bluetooth==0.7.0"]
|
||||||
}
|
}
|
||||||
|
138
homeassistant/components/togrill/number.py
Normal file
138
homeassistant/components/togrill/number.py
Normal file
@@ -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)
|
@@ -122,6 +122,8 @@ class ToGrillSensor(ToGrillEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType:
|
def native_value(self) -> StateType:
|
||||||
"""Get current value."""
|
"""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 self.entity_description.packet_extract(packet)
|
||||||
return None
|
return None
|
||||||
|
@@ -22,11 +22,30 @@
|
|||||||
"failed_to_read_config": "Failed to read config from device"
|
"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": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"temperature": {
|
"temperature": {
|
||||||
"name": "Probe {probe_number}"
|
"name": "Probe {probe_number}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"temperature_target": {
|
||||||
|
"name": "Target {probe_number}"
|
||||||
|
},
|
||||||
|
"alarm_interval": {
|
||||||
|
"name": "Alarm interval"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
355
tests/components/togrill/snapshots/test_number.ambr
Normal file
355
tests/components/togrill/snapshots/test_number.ambr
Normal file
@@ -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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 5,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
|
||||||
|
'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': <UnitOfTime.MINUTES: 'min'>,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# 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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 5,
|
||||||
|
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.pro_05_alarm_interval',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
|
||||||
|
'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': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# 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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.pro_05_target_1',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
|
||||||
|
'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': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# 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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.pro_05_target_2',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 5,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
|
||||||
|
'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': <UnitOfTime.MINUTES: 'min'>,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# 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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 5,
|
||||||
|
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.pro_05_alarm_interval',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
|
||||||
|
'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': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# 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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.pro_05_target_1',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
}),
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
|
||||||
|
'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': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# 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': <NumberMode.BOX: 'box'>,
|
||||||
|
'step': 1.0,
|
||||||
|
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'number.pro_05_target_2',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unknown',
|
||||||
|
})
|
||||||
|
# ---
|
243
tests/components/togrill/test_number.py
Normal file
243
tests/components/togrill/test_number.py
Normal file
@@ -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,
|
||||||
|
)
|
Reference in New Issue
Block a user