Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/opower/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
"requirements": ["opower==0.15.1"]
"requirements": ["opower==0.15.2"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/sonos/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
},
"select": {
"speech_dialog_level": {
"name": "Dialog level",
"name": "Speech enhancement",
"state": {
"off": "[%key:common::state::off%]",
"low": "[%key:common::state::low%]",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/togrill/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator

_PLATFORMS: list[Platform] = [Platform.SENSOR]
_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER]


async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool:
Expand Down
38 changes: 33 additions & 5 deletions homeassistant/components/togrill/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@

from datetime import timedelta
import logging
from typing import TypeVar

from bleak.exc import BleakError
from togrill_bluetooth.client import Client
from togrill_bluetooth.exceptions import DecodeError
from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify
from togrill_bluetooth.packets import (
Packet,
PacketA0Notify,
PacketA1Notify,
PacketA8Write,
)

from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
Expand All @@ -25,11 +31,15 @@
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import CONF_PROBE_COUNT

type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]

SCAN_INTERVAL = timedelta(seconds=30)
LOGGER = logging.getLogger(__name__)

PacketType = TypeVar("PacketType", bound=Packet)


def get_version_string(packet: PacketA0Notify) -> str:
"""Construct a version string from packet data."""
Expand All @@ -44,7 +54,7 @@ class DeviceFailed(UpdateFailed):
"""Update failed due to device disconnected."""


class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]):
class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Packet]]):
"""Class to manage fetching data."""

config_entry: ToGrillConfigEntry
Expand Down Expand Up @@ -86,7 +96,12 @@ async def _connect_and_update_registry(self) -> Client:
if not device:
raise DeviceNotFound("Unable to find device")

client = await Client.connect(device, self._notify_callback)
try:
client = await Client.connect(device, self._notify_callback)
except BleakError as exc:
self.logger.debug("Connection failed", exc_info=True)
raise DeviceNotFound("Unable to connect to device") from exc

try:
packet_a0 = await client.read(PacketA0Notify)
except (BleakError, DecodeError) as exc:
Expand Down Expand Up @@ -123,16 +138,29 @@ async def _get_connected_client(self) -> Client:
self.client = await self._connect_and_update_registry()
return self.client

def get_packet(
self, packet_type: type[PacketType], probe=None
) -> PacketType | None:
"""Get a cached packet of a certain type."""

if packet := self.data.get((packet_type.type, probe)):
assert isinstance(packet, packet_type)
return packet
return None

def _notify_callback(self, packet: Packet):
self.data[packet.type] = packet
probe = getattr(packet, "probe", None)
self.data[(packet.type, probe)] = packet
self.async_update_listeners()

async def _async_update_data(self) -> dict[int, Packet]:
async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]:
"""Poll the device."""
client = await self._get_connected_client()
try:
await client.request(PacketA0Notify)
await client.request(PacketA1Notify)
for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1):
await client.write(PacketA8Write(probe=probe))
except BleakError as exc:
raise DeviceFailed(f"Device failed {exc}") from exc
return self.data
Expand Down
33 changes: 32 additions & 1 deletion homeassistant/components/togrill/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

from __future__ import annotations

from bleak.exc import BleakError
from togrill_bluetooth.client import Client
from togrill_bluetooth.exceptions import BaseError
from togrill_bluetooth.packets import PacketWrite

from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .coordinator import ToGrillCoordinator
from .const import DOMAIN
from .coordinator import LOGGER, ToGrillCoordinator


class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
Expand All @@ -16,3 +23,27 @@ def __init__(self, coordinator: ToGrillCoordinator) -> None:
"""Initialize coordinator entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info

def _get_client(self) -> Client:
client = self.coordinator.client
if client is None or not client.is_connected:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="disconnected"
)
return client

async def _write_packet(self, packet: PacketWrite) -> None:
client = self._get_client()
try:
await client.write(packet)
except BleakError as exc:
LOGGER.debug("Failed to write", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="communication_failed"
) from exc
except BaseError as exc:
LOGGER.debug("Failed to write", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="rejected"
) from exc
await self.coordinator.async_request_refresh()
1 change: 1 addition & 0 deletions homeassistant/components/togrill/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": ["bluetooth"],
"documentation": "https://www.home-assistant.io/integrations/togrill",
"iot_class": "local_push",
"loggers": ["togrill_bluetooth"],
"quality_scale": "bronze",
"requirements": ["togrill-bluetooth==0.7.0"]
}
138 changes: 138 additions & 0 deletions homeassistant/components/togrill/number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Support for number entities."""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any

from togrill_bluetooth.packets import (
PacketA0Notify,
PacketA6Write,
PacketA8Notify,
PacketA301Write,
PacketWrite,
)

from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import ToGrillConfigEntry
from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .coordinator import ToGrillCoordinator
from .entity import ToGrillEntity

PARALLEL_UPDATES = 0


@dataclass(kw_only=True, frozen=True)
class ToGrillNumberEntityDescription(NumberEntityDescription):
"""Description of entity."""

get_value: Callable[[ToGrillCoordinator], float | None]
set_packet: Callable[[float], PacketWrite]
entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True


def _get_temperature_target_description(
probe_number: int,
) -> ToGrillNumberEntityDescription:
def _set_packet(value: float | None) -> PacketWrite:
if value == 0.0:
value = None
return PacketA301Write(probe=probe_number, target=value)

def _get_value(coordinator: ToGrillCoordinator) -> float | None:
if packet := coordinator.get_packet(PacketA8Notify, probe_number):
if packet.alarm_type == PacketA8Notify.AlarmType.TEMPERATURE_TARGET:
return packet.temperature_1
return None

return ToGrillNumberEntityDescription(
key=f"temperature_target_{probe_number}",
translation_key="temperature_target",
translation_placeholders={"probe_number": f"{probe_number}"},
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=0,
native_max_value=250,
mode=NumberMode.BOX,
set_packet=_set_packet,
get_value=_get_value,
entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT],
)


ENTITY_DESCRIPTIONS = (
*[
_get_temperature_target_description(probe_number)
for probe_number in range(1, MAX_PROBE_COUNT + 1)
],
ToGrillNumberEntityDescription(
key="alarm_interval",
translation_key="alarm_interval",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
native_min_value=0,
native_max_value=15,
native_step=5,
mode=NumberMode.BOX,
set_packet=lambda x: (
PacketA6Write(temperature_unit=None, alarm_interval=round(x))
),
get_value=lambda x: (
packet.alarm_interval if (packet := x.get_packet(PacketA0Notify)) else None
),
),
)


async def async_setup_entry(
hass: HomeAssistant,
entry: ToGrillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number based on a config entry."""

coordinator = entry.runtime_data

async_add_entities(
ToGrillNumber(coordinator, entity_description)
for entity_description in ENTITY_DESCRIPTIONS
if entity_description.entity_supported(entry.data)
)


class ToGrillNumber(ToGrillEntity, NumberEntity):
"""Representation of a number."""

entity_description: ToGrillNumberEntityDescription

def __init__(
self,
coordinator: ToGrillCoordinator,
entity_description: ToGrillNumberEntityDescription,
) -> None:
"""Initialize."""

super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.address}_{entity_description.key}"

@property
def native_value(self) -> float | None:
"""Return the value reported by the number."""
return self.entity_description.get_value(self.coordinator)

async def async_set_native_value(self, value: float) -> None:
"""Set value on device."""

packet = self.entity_description.set_packet(value)
await self._write_packet(packet)
4 changes: 3 additions & 1 deletion homeassistant/components/togrill/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def available(self) -> bool:
@property
def native_value(self) -> StateType:
"""Get current value."""
if packet := self.coordinator.data.get(self.entity_description.packet_type):
if packet := self.coordinator.data.get(
(self.entity_description.packet_type, None)
):
return self.entity_description.packet_extract(packet)
return None
19 changes: 19 additions & 0 deletions homeassistant/components/togrill/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,30 @@
"failed_to_read_config": "Failed to read config from device"
}
},
"exceptions": {
"disconnected": {
"message": "The device is disconnected"
},
"communication_failed": {
"message": "Communication failed with the device"
},
"rejected": {
"message": "Data was rejected by device"
}
},
"entity": {
"sensor": {
"temperature": {
"name": "Probe {probe_number}"
}
},
"number": {
"temperature_target": {
"name": "Target {probe_number}"
},
"alarm_interval": {
"name": "Alarm interval"
}
}
}
}
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/components/sonos/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from tests.common import async_fire_time_changed

SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_dialog_level"
SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_speech_enhancement"


@pytest.fixture(name="platform_select", autouse=True)
Expand Down
Loading
Loading