diff --git a/CODEOWNERS b/CODEOWNERS index d52349d49e8..9a7b961748c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1597,6 +1597,8 @@ build.json @home-assistant/supervisor /tests/components/todo/ @home-assistant/core /homeassistant/components/todoist/ @boralyl /tests/components/todoist/ @boralyl +/homeassistant/components/togrill/ @elupus +/tests/components/togrill/ @elupus /homeassistant/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr /homeassistant/components/tomorrowio/ @raman325 @lymanepp diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py new file mode 100644 index 00000000000..e938c56b9ee --- /dev/null +++ b/homeassistant/components/togrill/__init__.py @@ -0,0 +1,33 @@ +"""The ToGrill integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Set up ToGrill Bluetooth from a config entry.""" + + coordinator = ToGrillCoordinator(hass, entry) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as exc: + if not isinstance(exc.__cause__, DeviceNotFound): + raise + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/togrill/config_flow.py b/homeassistant/components/togrill/config_flow.py new file mode 100644 index 00000000000..29d930e7961 --- /dev/null +++ b/homeassistant/components/togrill/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for the ToGrill integration.""" + +from __future__ import annotations + +from typing import Any + +from bleak.exc import BleakError +from togrill_bluetooth import SUPPORTED_DEVICES +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import PacketA0Notify +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow + +from .const import CONF_PROBE_COUNT, DOMAIN +from .coordinator import LOGGER + +_TIMEOUT = 10 + + +async def read_config_data( + hass: HomeAssistant, info: BluetoothServiceInfoBleak +) -> dict[str, Any]: + """Read config from device.""" + + try: + client = await Client.connect(info.device) + except BleakError as exc: + LOGGER.debug("Failed to connect", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + + try: + packet_a0 = await client.read(PacketA0Notify) + except BleakError as exc: + LOGGER.debug("Failed to read data", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + finally: + await client.disconnect() + + return { + CONF_MODEL: info.name, + CONF_ADDRESS: info.address, + CONF_PROBE_COUNT: packet_a0.probe_count, + } + + +class ToGrillBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ToGrillBluetooth.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovery_infos: dict[str, BluetoothServiceInfoBleak] = {} + + async def _async_create_entry_internal( + self, info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + config_data = await read_config_data(self.hass, info) + + return self.async_create_entry( + title=config_data[CONF_MODEL], + data=config_data, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + if discovery_info.name not in SUPPORTED_DEVICES: + return self.async_abort(reason="not_supported") + + self._discovery_info = discovery_info + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + assert self._discovery_info is not None + discovery_info = self._discovery_info + + if user_input is not None: + return await self._async_create_entry_internal(discovery_info) + + self._set_confirm_only() + placeholders = {"name": discovery_info.name} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return await self._async_create_entry_internal( + self._discovery_infos[address] + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, True): + address = discovery_info.address + if ( + address in current_addresses + or address in self._discovery_infos + or discovery_info.name not in SUPPORTED_DEVICES + ): + continue + self._discovery_infos[address] = discovery_info + + if not self._discovery_infos: + return self.async_abort(reason="no_devices_found") + + addresses = {info.address: info.name for info in self._discovery_infos.values()} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}), + ) diff --git a/homeassistant/components/togrill/const.py b/homeassistant/components/togrill/const.py new file mode 100644 index 00000000000..dd2fe820919 --- /dev/null +++ b/homeassistant/components/togrill/const.py @@ -0,0 +1,8 @@ +"""Constants for the ToGrill integration.""" + +DOMAIN = "togrill" + +MAX_PROBE_COUNT = 6 + +CONF_PROBE_COUNT = "probe_count" +CONF_VERSION = "version" diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py new file mode 100644 index 00000000000..b79e4350e1e --- /dev/null +++ b/homeassistant/components/togrill/coordinator.py @@ -0,0 +1,148 @@ +"""Coordinator for the ToGrill Bluetooth integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +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 homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothCallbackMatcher, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_register_callback, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant, callback +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 + +type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] + +SCAN_INTERVAL = timedelta(seconds=30) +LOGGER = logging.getLogger(__name__) + + +def get_version_string(packet: PacketA0Notify) -> str: + """Construct a version string from packet data.""" + return f"{packet.version_major}.{packet.version_minor}" + + +class DeviceNotFound(UpdateFailed): + """Update failed due to device disconnected.""" + + +class DeviceFailed(UpdateFailed): + """Update failed due to device disconnected.""" + + +class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): + """Class to manage fetching data.""" + + config_entry: ToGrillConfigEntry + client: Client | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: ToGrillConfigEntry, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + config_entry=config_entry, + name="ToGrill", + update_interval=SCAN_INTERVAL, + ) + self.address = config_entry.data[CONF_ADDRESS] + self.data = {} + self.device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, self.address)} + ) + + config_entry.async_on_unload( + async_register_callback( + hass, + self._async_handle_bluetooth_event, + BluetoothCallbackMatcher(address=self.address, connectable=True), + BluetoothScanningMode.ACTIVE, + ) + ) + + async def _connect_and_update_registry(self) -> Client: + """Update device registry data.""" + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) + if not device: + raise DeviceNotFound("Unable to find device") + + client = await Client.connect(device, self._notify_callback) + try: + packet_a0 = await client.read(PacketA0Notify) + except (BleakError, DecodeError) as exc: + await client.disconnect() + raise DeviceFailed(f"Device failed {exc}") from exc + + config_entry = self.config_entry + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_BLUETOOTH, self.address)}, + name=config_entry.data[CONF_MODEL], + model=config_entry.data[CONF_MODEL], + sw_version=get_version_string(packet_a0), + ) + + return client + + async def async_shutdown(self) -> None: + """Shutdown coordinator and disconnect from device.""" + await super().async_shutdown() + if self.client: + await self.client.disconnect() + self.client = None + + async def _get_connected_client(self) -> Client: + if self.client and not self.client.is_connected: + await self.client.disconnect() + self.client = None + if self.client: + return self.client + + self.client = await self._connect_and_update_registry() + return self.client + + def _notify_callback(self, packet: Packet): + self.data[packet.type] = packet + self.async_update_listeners() + + async def _async_update_data(self) -> dict[int, Packet]: + """Poll the device.""" + client = await self._get_connected_client() + try: + await client.request(PacketA0Notify) + await client.request(PacketA1Notify) + except BleakError as exc: + raise DeviceFailed(f"Device failed {exc}") from exc + return self.data + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + if not self.client and isinstance(self.last_exception, DeviceNotFound): + self.hass.async_create_task(self.async_refresh()) diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py new file mode 100644 index 00000000000..c1a254557c5 --- /dev/null +++ b/homeassistant/components/togrill/entity.py @@ -0,0 +1,18 @@ +"""Provides the base entities.""" + +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ToGrillCoordinator + + +class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): + """Coordinator entity for Gardena Bluetooth.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ToGrillCoordinator) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json new file mode 100644 index 00000000000..7d777b8ae67 --- /dev/null +++ b/homeassistant/components/togrill/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "togrill", + "name": "ToGrill", + "bluetooth": [ + { + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + "connectable": true + } + ], + "codeowners": ["@elupus"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/togrill", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["togrill-bluetooth==0.4.0"] +} diff --git a/homeassistant/components/togrill/quality_scale.yaml b/homeassistant/components/togrill/quality_scale.yaml new file mode 100644 index 00000000000..6dd44090f80 --- /dev/null +++ b/homeassistant/components/togrill/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration only has a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration only has a single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not need any websession + strict-typing: todo diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py new file mode 100644 index 00000000000..7298e4b971b --- /dev/null +++ b/homeassistant/components/togrill/sensor.py @@ -0,0 +1,127 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, cast + +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +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 ToGrillSensorEntityDescription(SensorEntityDescription): + """Description of entity.""" + + packet_type: int + packet_extract: Callable[[Packet], StateType] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_description(probe_number: int): + def _get(packet: Packet) -> StateType: + assert isinstance(packet, PacketA1Notify) + if len(packet.temperatures) < probe_number: + return None + temperature = packet.temperatures[probe_number - 1] + if temperature is None: + return None + return temperature + + def _supported(config: Mapping[str, Any]): + return probe_number <= config[CONF_PROBE_COUNT] + + return ToGrillSensorEntityDescription( + key=f"temperature_{probe_number}", + translation_key="temperature", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + packet_type=PacketA1Notify.type, + packet_extract=_get, + entity_supported=_supported, + ) + + +ENTITY_DESCRIPTIONS = ( + ToGrillSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + packet_type=PacketA0Notify.type, + packet_extract=lambda packet: cast(PacketA0Notify, packet).battery, + ), + *[ + _get_temperature_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillSensor(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillSensor(ToGrillEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: ToGrillSensorEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillSensorEntityDescription, + ) -> None: + """Initialize sensor.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.native_value is not None + + @property + def native_value(self) -> StateType: + """Get current value.""" + if packet := self.coordinator.data.get(self.entity_description.packet_type): + return self.entity_description.packet_extract(packet) + return None diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json new file mode 100644 index 00000000000..1b75e387221 --- /dev/null +++ b/homeassistant/components/togrill/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select the device to add." + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "failed_to_read_config": "Failed to read config from device" + } + }, + "entity": { + "sensor": { + "temperature": { + "name": "Probe {probe_number}" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index da6cab4bc22..fcaa824ff39 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -834,6 +834,12 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 76, }, + { + "connectable": True, + "domain": "togrill", + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "xiaomi_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8de75b21bba..823bd339d51 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -653,6 +653,7 @@ FLOWS = { "tilt_pi", "time_date", "todoist", + "togrill", "tolo", "tomorrowio", "toon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e9a8f46a496..d40f882240b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6790,6 +6790,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "togrill": { + "name": "ToGrill", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "tolo": { "name": "TOLO Sauna", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 2cdb87b5ad2..a876b41b8db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2955,6 +2955,9 @@ tmb==0.0.4 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.4.0 + # homeassistant.components.tolo tololib==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9dfd138977c..7059e5691ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2432,6 +2432,9 @@ tilt-pi==0.2.1 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.4.0 + # homeassistant.components.tolo tololib==1.2.2 diff --git a/tests/components/togrill/__init__.py b/tests/components/togrill/__init__.py new file mode 100644 index 00000000000..9e0d164ae2a --- /dev/null +++ b/tests/components/togrill/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the ToGrill Bluetooth integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +TOGRILL_SERVICE_INFO = BluetoothServiceInfo( + name="Pro-05", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + +TOGRILL_SERVICE_INFO_NO_NAME = BluetoothServiceInfo( + name="", + address="00000000-0000-0000-0000-000000000002", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + +async def setup_entry( + hass: HomeAssistant, mock_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Make sure the device is available.""" + + with patch("homeassistant.components.togrill._PLATFORMS", platforms): + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py new file mode 100644 index 00000000000..6b028ca5270 --- /dev/null +++ b/tests/components/togrill/conftest.py @@ -0,0 +1,96 @@ +"""Common fixtures for the ToGrill tests.""" + +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketNotify + +from homeassistant.components.togrill.const import CONF_PROBE_COUNT, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_MODEL + +from . import TOGRILL_SERVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Create hass config fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: TOGRILL_SERVICE_INFO.address, + CONF_MODEL: "Pro-05", + CONF_PROBE_COUNT: 2, + }, + unique_id=TOGRILL_SERVICE_INFO.address, + ) + + +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.togrill.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.togrill.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mock]: + """Auto mock bluetooth.""" + + client_object = Mock(spec=Client) + client_object.mocked_notify = None + + async def _connect( + address: str, callback: Callable[[Packet], None] | None = None + ) -> Mock: + client_object.mocked_notify = callback + return client_object + + async def _disconnect() -> None: + pass + + async def _request(packet_type: type[Packet]) -> None: + if packet_type is PacketA0Notify: + client_object.mocked_notify(PacketA0Notify(0, 0, 0, 0, 0, False, 0, False)) + + async def _read(packet_type: type[PacketNotify]) -> PacketNotify: + if packet_type is PacketA0Notify: + return PacketA0Notify(0, 0, 0, 0, 0, False, 0, False) + raise NotImplementedError + + mock_client_class.connect.side_effect = _connect + client_object.request.side_effect = _request + client_object.read.side_effect = _read + client_object.disconnect.side_effect = _disconnect + client_object.is_connected = True + + return client_object + + +@pytest.fixture(autouse=True) +def mock_client_class() -> Generator[Mock]: + """Auto mock bluetooth.""" + + with ( + patch( + "homeassistant.components.togrill.config_flow.Client", autospec=True + ) as client_class, + patch("homeassistant.components.togrill.coordinator.Client", new=client_class), + ): + yield client_class diff --git a/tests/components/togrill/snapshots/test_sensor.ambr b/tests/components/togrill/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bc55d831500 --- /dev/null +++ b/tests/components/togrill/snapshots/test_sensor.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_setup[battery][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[battery][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/togrill/test_config_flow.py b/tests/components/togrill/test_config_flow.py new file mode 100644 index 00000000000..2620a88f7f2 --- /dev/null +++ b/tests/components/togrill/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test the ToGrill config flow.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.togrill.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import TOGRILL_SERVICE_INFO, TOGRILL_SERVICE_INFO_NO_NAME, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_selection( + hass: HomeAssistant, +) -> None: + """Test we can select a device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address + + +async def test_failed_connect( + hass: HomeAssistant, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test failure to connect result.""" + + mock_client_class.connect.side_effect = BleakError("Failed to connect") + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_failed_read( + hass: HomeAssistant, + mock_client: Mock, +) -> None: + """Test failure to read from device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_client.read.side_effect = BleakError("something went wrong") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_no_devices( + hass: HomeAssistant, +) -> None: + """Test missing device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_duplicate_setup( + hass: HomeAssistant, + mock_entry: MockConfigEntry, +) -> None: + """Test we can not setup a device again.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + await setup_entry(hass, mock_entry, []) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_bluetooth( + hass: HomeAssistant, +) -> None: + """Test bluetooth device discovery.""" + + # Inject the service info will trigger the flow to start + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN))) + + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address diff --git a/tests/components/togrill/test_init.py b/tests/components/togrill/test_init.py new file mode 100644 index 00000000000..24f19ba367e --- /dev/null +++ b/tests/components/togrill/test_init.py @@ -0,0 +1,60 @@ +"""Test for initialization of ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_setup_device_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup works with device present.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_not_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup succeeds if device is missing.""" + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_failing( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup fails if device is not responding.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + mock_client.is_connected = False + mock_client.read.side_effect = BleakError("Failed to read data") + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py new file mode 100644 index 00000000000..d7662d483af --- /dev/null +++ b/tests/components/togrill/test_sensor.py @@ -0,0 +1,59 @@ +"""Test sensors for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +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, + ) + ], + id="battery", + ), + pytest.param([PacketA1Notify([10, None])], id="temp_data"), + pytest.param([PacketA1Notify([10])], id="temp_data_missing_probe"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the sensors.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id)