mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Add Chef iQ integration (#174171)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
35a2dcb222
commit
876ffe3b6c
@@ -142,6 +142,7 @@ homeassistant.components.canary.*
|
||||
homeassistant.components.casper_glow.*
|
||||
homeassistant.components.centriconnect.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
homeassistant.components.chef_iq.*
|
||||
homeassistant.components.clickatell.*
|
||||
homeassistant.components.clicksend.*
|
||||
homeassistant.components.climate.*
|
||||
|
||||
Generated
+2
@@ -299,6 +299,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/cert_expiry/ @jjlawren
|
||||
/homeassistant/components/chacon_dio/ @cnico
|
||||
/tests/components/chacon_dio/ @cnico
|
||||
/homeassistant/components/chef_iq/ @Invader444
|
||||
/tests/components/chef_iq/ @Invader444
|
||||
/homeassistant/components/chess_com/ @joostlek
|
||||
/tests/components/chess_com/ @joostlek
|
||||
/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The Chef iQ integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from chefiq_ble import ChefIqBluetoothDeviceData
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ChefIqConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ChefIqConfigEntry) -> bool:
|
||||
"""Set up Chef iQ BLE device from a config entry."""
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
data = ChefIqBluetoothDeviceData()
|
||||
|
||||
coordinator = entry.runtime_data = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
update_method=data.update,
|
||||
# The probe is broadcast-only; all data comes from advertisements and it
|
||||
# is never connected to.
|
||||
connectable=False,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
# only start after all platforms have had a chance to subscribe
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ChefIqConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Config flow for the Chef iQ integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from chefiq_ble import ChefIqBluetoothDeviceData as DeviceData
|
||||
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
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class ChefIqConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Chef iQ."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_device: DeviceData | None = None
|
||||
self._discovered_devices: dict[str, str] = {}
|
||||
|
||||
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()
|
||||
device = DeviceData()
|
||||
if not device.supported(discovery_info):
|
||||
return self.async_abort(reason="not_supported")
|
||||
self._discovery_info = discovery_info
|
||||
self._discovered_device = device
|
||||
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._discovered_device is not None
|
||||
device = self._discovered_device
|
||||
assert self._discovery_info is not None
|
||||
discovery_info = self._discovery_info
|
||||
title = device.title or device.get_device_name() or discovery_info.name
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title=title, data={})
|
||||
|
||||
self._set_confirm_only()
|
||||
placeholders = {"name": title}
|
||||
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 self.async_create_entry(
|
||||
title=self._discovered_devices[address], data={}
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
continue
|
||||
device = DeviceData()
|
||||
if device.supported(discovery_info):
|
||||
self._discovered_devices[address] = (
|
||||
device.title or device.get_device_name() or discovery_info.name
|
||||
)
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Chef iQ integration."""
|
||||
|
||||
DOMAIN = "chef_iq"
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Support for Chef iQ devices."""
|
||||
|
||||
from chefiq_ble import DeviceKey
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothEntityKey,
|
||||
)
|
||||
|
||||
|
||||
def device_key_to_bluetooth_entity_key(
|
||||
device_key: DeviceKey,
|
||||
) -> PassiveBluetoothEntityKey:
|
||||
"""Convert a device key to an entity key."""
|
||||
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "chef_iq",
|
||||
"name": "Chef iQ",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": false,
|
||||
"manufacturer_id": 1485
|
||||
}
|
||||
],
|
||||
"codeowners": ["@Invader444"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/chef_iq",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["chefiq_ble"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["chefiq-ble==1.0.1"]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom actions are defined.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
Passive integration; data is pushed via Bluetooth advertisements.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Subscriptions are handled by the PassiveBluetoothProcessorEntity base class.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure:
|
||||
status: exempt
|
||||
comment: |
|
||||
Passive device; presence is confirmed by the discovered advertisement, no
|
||||
connection is made.
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Sleepy passive device that may not be advertising at setup time; no
|
||||
connection is made.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
Availability is provided by the passive bluetooth coordinator. These are
|
||||
sleepy devices, so once seen the entities use assumed_state rather than
|
||||
becoming unavailable.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Handled by the passive bluetooth coordinator.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: No authentication is required.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device is identified by its static Bluetooth address; there is no
|
||||
network information to update.
|
||||
discovery:
|
||||
status: done
|
||||
comment: The integration is discovered via Bluetooth.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Each probe is set up as its own config entry.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: No custom exceptions are defined.
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Entities derive their icons from their device classes.
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is nothing to reconfigure; the device is identified by its fixed
|
||||
Bluetooth address.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues are raised.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: A single device per config entry; removing the entry removes it.
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: done
|
||||
comment: |
|
||||
The chefiq-ble dependency is a pure advertisement parser and performs no
|
||||
blocking I/O.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: The integration does not make HTTP requests.
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Support for Chef iQ sensors."""
|
||||
|
||||
from chefiq_ble import ChefIqSensor, SensorUpdate
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
|
||||
|
||||
from . import ChefIqConfigEntry
|
||||
from .device import device_key_to_bluetooth_entity_key
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def _temperature_description(
|
||||
sensor: ChefIqSensor, *, enabled_default: bool = True
|
||||
) -> SensorEntityDescription:
|
||||
"""Build a standard Celsius temperature description for a Chef iQ sensor."""
|
||||
return SensorEntityDescription(
|
||||
key=sensor,
|
||||
translation_key=sensor,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_registry_enabled_default=enabled_default,
|
||||
)
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
|
||||
ChefIqSensor.FOOD_TEMPERATURE: _temperature_description(
|
||||
ChefIqSensor.FOOD_TEMPERATURE
|
||||
),
|
||||
ChefIqSensor.AMBIENT_TEMPERATURE: _temperature_description(
|
||||
ChefIqSensor.AMBIENT_TEMPERATURE
|
||||
),
|
||||
ChefIqSensor.PROBE_TIP_1_TEMPERATURE: _temperature_description(
|
||||
ChefIqSensor.PROBE_TIP_1_TEMPERATURE, enabled_default=False
|
||||
),
|
||||
ChefIqSensor.PROBE_TIP_2_TEMPERATURE: _temperature_description(
|
||||
ChefIqSensor.PROBE_TIP_2_TEMPERATURE, enabled_default=False
|
||||
),
|
||||
ChefIqSensor.PROBE_TIP_3_TEMPERATURE: _temperature_description(
|
||||
ChefIqSensor.PROBE_TIP_3_TEMPERATURE, enabled_default=False
|
||||
),
|
||||
ChefIqSensor.PROBE_TIP_4_TEMPERATURE: _temperature_description(
|
||||
ChefIqSensor.PROBE_TIP_4_TEMPERATURE, enabled_default=False
|
||||
),
|
||||
ChefIqSensor.SOC_TEMPERATURE: SensorEntityDescription(
|
||||
key=ChefIqSensor.SOC_TEMPERATURE,
|
||||
translation_key=ChefIqSensor.SOC_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ChefIqSensor.BATTERY_PERCENT: SensorEntityDescription(
|
||||
key=ChefIqSensor.BATTERY_PERCENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
# signal_strength (RSSI) is emitted automatically by the BluetoothData base
|
||||
# class, so it must have a description here.
|
||||
ChefIqSensor.SIGNAL_STRENGTH: SensorEntityDescription(
|
||||
key=ChefIqSensor.SIGNAL_STRENGTH,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def sensor_update_to_bluetooth_data_update(
|
||||
sensor_update: SensorUpdate,
|
||||
) -> PassiveBluetoothDataUpdate:
|
||||
"""Convert a sensor update to a bluetooth data update."""
|
||||
return PassiveBluetoothDataUpdate(
|
||||
devices={
|
||||
device_id: sensor_device_info_to_hass_device_info(device_info)
|
||||
for device_id, device_info in sensor_update.devices.items()
|
||||
},
|
||||
entity_descriptions={
|
||||
device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
|
||||
device_key.key
|
||||
]
|
||||
for device_key in sensor_update.entity_descriptions
|
||||
if device_key.key in SENSOR_DESCRIPTIONS
|
||||
},
|
||||
entity_data={
|
||||
device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
|
||||
for device_key, sensor_values in sensor_update.entity_values.items()
|
||||
if device_key.key in SENSOR_DESCRIPTIONS
|
||||
},
|
||||
entity_names={},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ChefIqConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Chef iQ BLE sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
|
||||
entry.async_on_unload(
|
||||
processor.async_add_entities_listener(
|
||||
ChefIqBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class ChefIqBluetoothSensorEntity(
|
||||
PassiveBluetoothProcessorEntity[
|
||||
PassiveBluetoothDataProcessor[float | int | None, SensorUpdate]
|
||||
],
|
||||
SensorEntity,
|
||||
):
|
||||
"""Representation of a Chef iQ sensor."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | int | None:
|
||||
"""Return the native value."""
|
||||
return self.processor.entity_data.get(self.entity_key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available.
|
||||
|
||||
The sensor is only created when the device is seen.
|
||||
|
||||
Since these are sleepy devices which stop broadcasting
|
||||
when not in use, we can't rely on the last update time
|
||||
so once we have seen the device we always return True.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return True if the device is no longer broadcasting."""
|
||||
return not self.processor.available
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"not_supported": "Device not supported"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "Select the Chef iQ probe you want to set up"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ambient_temperature": {
|
||||
"name": "Ambient temperature"
|
||||
},
|
||||
"food_temperature": {
|
||||
"name": "Food temperature"
|
||||
},
|
||||
"probe_tip_1_temperature": {
|
||||
"name": "Probe tip 1 temperature"
|
||||
},
|
||||
"probe_tip_2_temperature": {
|
||||
"name": "Probe tip 2 temperature"
|
||||
},
|
||||
"probe_tip_3_temperature": {
|
||||
"name": "Probe tip 3 temperature"
|
||||
},
|
||||
"probe_tip_4_temperature": {
|
||||
"name": "Probe tip 4 temperature"
|
||||
},
|
||||
"soc_temperature": {
|
||||
"name": "SoC temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+5
@@ -93,6 +93,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "casper_glow",
|
||||
"local_name": "Jar*",
|
||||
},
|
||||
{
|
||||
"connectable": False,
|
||||
"domain": "chef_iq",
|
||||
"manufacturer_id": 1485,
|
||||
},
|
||||
{
|
||||
"domain": "dormakaba_dkey",
|
||||
"service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897",
|
||||
|
||||
Generated
+1
@@ -128,6 +128,7 @@ FLOWS = {
|
||||
"centriconnect",
|
||||
"cert_expiry",
|
||||
"chacon_dio",
|
||||
"chef_iq",
|
||||
"chess_com",
|
||||
"cielo_home",
|
||||
"cloudflare",
|
||||
|
||||
@@ -1031,6 +1031,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"chef_iq": {
|
||||
"name": "Chef iQ",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"chess_com": {
|
||||
"name": "Chess.com",
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -1176,6 +1176,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.chef_iq.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.clickatell.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+3
@@ -741,6 +741,9 @@ cached-ipaddress==1.1.2
|
||||
# homeassistant.components.caldav
|
||||
caldav==2.1.0
|
||||
|
||||
# homeassistant.components.chef_iq
|
||||
chefiq-ble==1.0.1
|
||||
|
||||
# homeassistant.components.chess_com
|
||||
chess-com-api==1.1.0
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for the Chef iQ integration."""
|
||||
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
|
||||
ADDRESS = "C9:14:65:CA:07:9A"
|
||||
TITLE = "CQ60 079A"
|
||||
|
||||
NOT_CHEFIQ_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="Not it",
|
||||
address="00:00:00:00:00:01",
|
||||
rssi=-63,
|
||||
manufacturer_data={3234: b"\x00\x01"},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
# Real CQ60 captures (firmware 5.0.0). The temperature packet (type 0x1) carries
|
||||
# the food/ambient/tip temperatures; the status packet (type 0x3) carries the
|
||||
# MAC address, battery percentage and SoC temperature.
|
||||
CHEFIQ_TEMPERATURE_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="CQ60",
|
||||
address="C9:14:65:CA:07:9A",
|
||||
rssi=-60,
|
||||
manufacturer_data={1485: bytes.fromhex("015024012b0135012e012b0130012401315a")},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
CHEFIQ_STATUS_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="CQ60",
|
||||
address="C9:14:65:CA:07:9A",
|
||||
rssi=-60,
|
||||
manufacturer_data={1485: bytes.fromhex("0350c91465ca079a64201e010301007ac4")},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
# A 0x05CD advertisement that passes the cheap probe pre-filter but is not a
|
||||
# recognised probe packet (unknown packet type 0x2); must be rejected.
|
||||
CHEFIQ_UNSUPPORTED_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="CQ60",
|
||||
address="C9:14:65:CA:07:9A",
|
||||
rssi=-60,
|
||||
manufacturer_data={1485: bytes.fromhex("0250")},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
# The iQ Sense base/hub advertises under the same manufacturer id (0x05CD) but
|
||||
# is not a probe; the config flow must reject it.
|
||||
IQ_SENSE_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="iQ Sense 540",
|
||||
address="94:54:C5:6D:8A:D6",
|
||||
rssi=-66,
|
||||
manufacturer_data={
|
||||
1485: bytes.fromhex("50754e427588e5133f8d4bb3a403113f437a871e05")
|
||||
},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Chef iQ test fixtures."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.chef_iq.const import DOMAIN
|
||||
|
||||
from . import ADDRESS
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_bluetooth(enable_bluetooth: None) -> None:
|
||||
"""Auto-enable Bluetooth for all tests."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.chef_iq.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(domain=DOMAIN, unique_id=ADDRESS)
|
||||
@@ -0,0 +1,517 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensors[sensor.cq60_079a_ambient_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cq60_079a_ambient_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Ambient temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Ambient temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.AMBIENT_TEMPERATURE: 'ambient_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-ambient_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_ambient_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A Ambient temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_ambient_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '29.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_battery-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.cq60_079a_battery',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Battery',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Battery',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-battery_percent',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_battery-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery',
|
||||
'friendly_name': 'CQ60 079A Battery',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_battery',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '100',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_food_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cq60_079a_food_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Food temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Food temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.FOOD_TEMPERATURE: 'food_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-food_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_food_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A Food temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_food_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '29.9',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_1_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_1_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Probe tip 1 temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Probe tip 1 temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.PROBE_TIP_1_TEMPERATURE: 'probe_tip_1_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-probe_tip_1_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_1_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A Probe tip 1 temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_1_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '30.9',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_2_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_2_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Probe tip 2 temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Probe tip 2 temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.PROBE_TIP_2_TEMPERATURE: 'probe_tip_2_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-probe_tip_2_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_2_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A Probe tip 2 temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_2_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '30.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_3_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_3_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Probe tip 3 temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Probe tip 3 temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.PROBE_TIP_3_TEMPERATURE: 'probe_tip_3_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-probe_tip_3_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_3_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A Probe tip 3 temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_3_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '29.9',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_4_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_4_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Probe tip 4 temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Probe tip 4 temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.PROBE_TIP_4_TEMPERATURE: 'probe_tip_4_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-probe_tip_4_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_probe_tip_4_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A Probe tip 4 temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_probe_tip_4_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '30.4',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_signal_strength-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.cq60_079a_signal_strength',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Signal strength',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Signal strength',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-signal_strength',
|
||||
'unit_of_measurement': 'dBm',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_signal_strength-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'signal_strength',
|
||||
'friendly_name': 'CQ60 079A Signal strength',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'dBm',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_signal_strength',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '-60',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_soc_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.cq60_079a_soc_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'SoC temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'SoC temperature',
|
||||
'platform': 'chef_iq',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <ChefIqSensor.SOC_TEMPERATURE: 'soc_temperature'>,
|
||||
'unique_id': 'C9:14:65:CA:07:9A-soc_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.cq60_079a_soc_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CQ60 079A SoC temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cq60_079a_soc_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '32',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Test the Chef iQ config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.chef_iq.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
|
||||
from . import (
|
||||
ADDRESS,
|
||||
CHEFIQ_TEMPERATURE_SERVICE_INFO,
|
||||
CHEFIQ_UNSUPPORTED_SERVICE_INFO,
|
||||
IQ_SENSE_SERVICE_INFO,
|
||||
NOT_CHEFIQ_SERVICE_INFO,
|
||||
TITLE,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
DISCOVERY = "homeassistant.components.chef_iq.config_flow.async_discovered_service_info"
|
||||
|
||||
|
||||
async def test_async_step_bluetooth_valid_device(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test discovery via bluetooth with a valid device."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=CHEFIQ_TEMPERATURE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
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["title"] == TITLE
|
||||
assert result["data"] == {}
|
||||
assert result["result"].unique_id == ADDRESS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_info",
|
||||
[
|
||||
NOT_CHEFIQ_SERVICE_INFO,
|
||||
IQ_SENSE_SERVICE_INFO,
|
||||
CHEFIQ_UNSUPPORTED_SERVICE_INFO,
|
||||
],
|
||||
)
|
||||
async def test_async_step_bluetooth_not_supported(
|
||||
hass: HomeAssistant, service_info: BluetoothServiceInfo
|
||||
) -> None:
|
||||
"""Test bluetooth discovery rejects advertisements that are not a probe."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=service_info,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "not_supported"
|
||||
|
||||
|
||||
async def test_async_step_user_no_devices_found(hass: HomeAssistant) -> None:
|
||||
"""Test setup from service info cache with no devices found."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_async_step_user_with_found_devices(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test setup from service info cache with devices found."""
|
||||
with patch(DISCOVERY, return_value=[CHEFIQ_TEMPERATURE_SERVICE_INFO]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": 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": ADDRESS},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TITLE
|
||||
assert result["data"] == {}
|
||||
assert result["result"].unique_id == ADDRESS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("discovered", "existing_entry"),
|
||||
[
|
||||
([IQ_SENSE_SERVICE_INFO], False),
|
||||
([CHEFIQ_TEMPERATURE_SERVICE_INFO], True),
|
||||
],
|
||||
)
|
||||
async def test_async_step_user_no_eligible_devices(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
discovered: list[BluetoothServiceInfo],
|
||||
existing_entry: bool,
|
||||
) -> None:
|
||||
"""Test the user step aborts when no eligible device can be offered.
|
||||
|
||||
Either the only discovered device is the unsupported iQ Sense hub, or the
|
||||
discovered probe is already configured.
|
||||
"""
|
||||
if existing_entry:
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch(DISCOVERY, return_value=discovered):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_async_step_bluetooth_devices_already_setup(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test we can't start a flow if there is already a config entry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=CHEFIQ_TEMPERATURE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> None:
|
||||
"""Test we can't start a flow for the same device twice."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=CHEFIQ_TEMPERATURE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=CHEFIQ_TEMPERATURE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
|
||||
async def test_async_step_user_device_added_between_steps(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test the device gets added via another flow between steps."""
|
||||
with patch(DISCOVERY, return_value=[CHEFIQ_TEMPERATURE_SERVICE_INFO]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"address": ADDRESS},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_async_step_user_replace_ignored(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test setup from service info can replace an ignored entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=ADDRESS,
|
||||
source=SOURCE_IGNORE,
|
||||
data={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
with patch(DISCOVERY, return_value=[CHEFIQ_TEMPERATURE_SERVICE_INFO]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": 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": ADDRESS},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TITLE
|
||||
assert result["result"].unique_id == ADDRESS
|
||||
|
||||
|
||||
async def test_async_step_user_takes_precedence_over_discovery(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test manual setup takes precedence over discovery."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=CHEFIQ_TEMPERATURE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
with patch(DISCOVERY, return_value=[CHEFIQ_TEMPERATURE_SERVICE_INFO]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"address": ADDRESS},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TITLE
|
||||
assert result["result"].unique_id == ADDRESS
|
||||
|
||||
# Verify the original discovery flow was aborted.
|
||||
assert not hass.config_entries.flow.async_progress(DOMAIN)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Test the Chef iQ sensors."""
|
||||
|
||||
from datetime import timedelta
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
||||
)
|
||||
from homeassistant.const import ATTR_ASSUMED_STATE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import CHEFIQ_STATUS_SERVICE_INFO, CHEFIQ_TEMPERATURE_SERVICE_INFO
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
from tests.components.bluetooth import (
|
||||
inject_bluetooth_service_info,
|
||||
patch_all_discovered_devices,
|
||||
patch_bluetooth_time,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setting up creates the sensors from the rotating packet types."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert not hass.states.async_all("sensor")
|
||||
|
||||
# The temperature packet creates the six temperatures (plus signal strength);
|
||||
# the status packet adds battery and SoC temperature.
|
||||
inject_bluetooth_service_info(hass, CHEFIQ_TEMPERATURE_SERVICE_INFO)
|
||||
await hass.async_block_till_done()
|
||||
inject_bluetooth_service_info(hass, CHEFIQ_STATUS_SERVICE_INFO)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_sleepy_device_keeps_state(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the probe keeps its state and goes to assumed_state when idle."""
|
||||
start_monotonic = time.monotonic()
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
inject_bluetooth_service_info(hass, CHEFIQ_TEMPERATURE_SERVICE_INFO)
|
||||
await hass.async_block_till_done()
|
||||
food = hass.states.get("sensor.cq60_079a_food_temperature")
|
||||
assert food.state == "29.9"
|
||||
assert ATTR_ASSUMED_STATE not in food.attributes
|
||||
|
||||
# Fast-forward past the stale-advertisement window with no advertisements.
|
||||
monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
|
||||
with (
|
||||
patch_bluetooth_time(monotonic_now),
|
||||
patch_all_discovered_devices([]),
|
||||
):
|
||||
async_fire_time_changed(
|
||||
hass,
|
||||
dt_util.utcnow()
|
||||
+ timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Sleepy devices keep their last value and report assumed_state.
|
||||
food = hass.states.get("sensor.cq60_079a_food_temperature")
|
||||
assert food.state == "29.9"
|
||||
assert food.attributes[ATTR_ASSUMED_STATE] is True
|
||||
Reference in New Issue
Block a user