Fjäråskupan kitchen fan (#53140)

* Add fjäråskupan fan control

* Update tests/components/fjaraskupan/conftest.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/fjaraskupan/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/fjaraskupan/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Increase manual update to 2 minutes

* Address review comments

* Switch to discovery flow

* Address more review comments

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Joakim Plate 2021-08-20 13:41:36 +02:00 committed by GitHub
parent 6218cd648d
commit 1f4c12195e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 529 additions and 0 deletions

View File

@ -317,6 +317,9 @@ omit =
homeassistant/components/firmata/switch.py
homeassistant/components/fitbit/*
homeassistant/components/fixer/sensor.py
homeassistant/components/fjaraskupan/__init__.py
homeassistant/components/fjaraskupan/const.py
homeassistant/components/fjaraskupan/fan.py
homeassistant/components/fleetgo/device_tracker.py
homeassistant/components/flexit/climate.py
homeassistant/components/flic/binary_sensor.py

View File

@ -163,6 +163,7 @@ homeassistant/components/filter/* @dgomes
homeassistant/components/fireservicerota/* @cyberjunky
homeassistant/components/firmata/* @DaAwesomeP
homeassistant/components/fixer/* @fabaff
homeassistant/components/fjaraskupan/* @elupus
homeassistant/components/flick_electric/* @ZephireNZ
homeassistant/components/flipr/* @cnico
homeassistant/components/flo/* @dmulcahey

View File

@ -0,0 +1,143 @@
"""The Fjäråskupan integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Callable
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from fjaraskupan import Device, State, device_filter
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DISPATCH_DETECTION, DOMAIN
PLATFORMS = ["fan"]
_LOGGER = logging.getLogger(__name__)
@dataclass
class DeviceState:
"""Store state of a device."""
device: Device
coordinator: DataUpdateCoordinator[State]
device_info: DeviceInfo
@dataclass
class EntryState:
"""Store state of config entry."""
scanner: BleakScanner
devices: dict[str, DeviceState]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Fjäråskupan from a config entry."""
scanner = BleakScanner()
state = EntryState(scanner, {})
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = state
async def detection_callback(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
if not device_filter(ble_device, advertisement_data):
return
_LOGGER.debug(
"Detection: %s %s - %s", ble_device.name, ble_device, advertisement_data
)
data = state.devices.get(ble_device.address)
if data:
data.device.detection_callback(ble_device, advertisement_data)
data.coordinator.async_set_updated_data(data.device.state)
else:
device = Device(ble_device)
device.detection_callback(ble_device, advertisement_data)
async def async_update_data():
"""Handle an explicit update request."""
await device.update()
return device.state
coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator(
hass,
logger=_LOGGER,
name="Fjaraskupan Updater",
update_interval=timedelta(seconds=120),
update_method=async_update_data,
)
coordinator.async_set_updated_data(device.state)
device_info: DeviceInfo = {
"identifiers": {(DOMAIN, ble_device.address)},
"manufacturer": "Fjäråskupan",
"name": "Fjäråskupan",
}
device_state = DeviceState(device, coordinator, device_info)
state.devices[ble_device.address] = device_state
async_dispatcher_send(
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", device_state
)
scanner.register_detection_callback(detection_callback)
await scanner.start()
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
@callback
def async_setup_entry_platform(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
constructor: Callable[[DeviceState], list[Entity]],
) -> None:
"""Set up a platform with added entities."""
entry_state: EntryState = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
entity
for device_state in entry_state.devices.values()
for entity in constructor(device_state)
)
@callback
def _detection(device_state: DeviceState) -> None:
async_add_entities(constructor(device_state))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", _detection
)
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id)
await entry_state.scanner.stop()
return unload_ok

View File

@ -0,0 +1,38 @@
"""Config flow for Fjäråskupan integration."""
from __future__ import annotations
import asyncio
import async_timeout
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from fjaraskupan import device_filter
from homeassistant.helpers.config_entry_flow import register_discovery_flow
from .const import DOMAIN
CONST_WAIT_TIME = 5.0
async def _async_has_devices(hass) -> bool:
"""Return if there are devices that can be discovered."""
event = asyncio.Event()
def detection(device: BLEDevice, advertisement_data: AdvertisementData):
if device_filter(device, advertisement_data):
event.set()
async with BleakScanner(detection_callback=detection):
try:
async with async_timeout.timeout(CONST_WAIT_TIME):
await event.wait()
except asyncio.TimeoutError:
return False
return True
register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices)

View File

@ -0,0 +1,5 @@
"""Constants for the Fjäråskupan integration."""
DOMAIN = "fjaraskupan"
DISPATCH_DETECTION = f"{DOMAIN}.detection"

View File

@ -0,0 +1,192 @@
"""Support for Fjäråskupan fans."""
from __future__ import annotations
from fjaraskupan import (
COMMAND_AFTERCOOKINGTIMERAUTO,
COMMAND_AFTERCOOKINGTIMERMANUAL,
COMMAND_AFTERCOOKINGTIMEROFF,
COMMAND_STOP_FAN,
Device,
State,
)
from homeassistant.components.fan import (
SUPPORT_PRESET_MODE,
SUPPORT_SET_SPEED,
FanEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from . import DeviceState, async_setup_entry_platform
ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"]
PRESET_MODE_NORMAL = "normal"
PRESET_MODE_AFTER_COOKING_MANUAL = "after_cooking_manual"
PRESET_MODE_AFTER_COOKING_AUTO = "after_cooking_auto"
PRESET_MODES = [
PRESET_MODE_NORMAL,
PRESET_MODE_AFTER_COOKING_AUTO,
PRESET_MODE_AFTER_COOKING_MANUAL,
]
PRESET_TO_COMMAND = {
PRESET_MODE_AFTER_COOKING_MANUAL: COMMAND_AFTERCOOKINGTIMERMANUAL,
PRESET_MODE_AFTER_COOKING_AUTO: COMMAND_AFTERCOOKINGTIMERAUTO,
PRESET_MODE_NORMAL: COMMAND_AFTERCOOKINGTIMEROFF,
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors dynamically through discovery."""
def _constructor(device_state: DeviceState):
return [
Fan(device_state.coordinator, device_state.device, device_state.device_info)
]
async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor)
class Fan(CoordinatorEntity[State], FanEntity):
"""Fan entity."""
def __init__(
self,
coordinator: DataUpdateCoordinator[State],
device: Device,
device_info: DeviceInfo,
) -> None:
"""Init fan entity."""
super().__init__(coordinator)
self._device = device
self._default_on_speed = 25
self._attr_name = device_info["name"]
self._attr_unique_id = device.address
self._attr_device_info = device_info
self._percentage = 0
self._preset_mode = PRESET_MODE_NORMAL
self._update_from_device_data(coordinator.data)
async def async_set_percentage(self, percentage: int) -> None:
"""Set speed."""
new_speed = percentage_to_ordered_list_item(
ORDERED_NAMED_FAN_SPEEDS, percentage
)
await self._device.send_fan_speed(int(new_speed))
self.coordinator.async_set_updated_data(self._device.state)
async def async_turn_on(
self,
speed: str = None,
percentage: int = None,
preset_mode: str = None,
**kwargs,
) -> None:
"""Turn on the fan."""
if preset_mode is None:
preset_mode = self._preset_mode
if percentage is None:
percentage = self._default_on_speed
new_speed = percentage_to_ordered_list_item(
ORDERED_NAMED_FAN_SPEEDS, percentage
)
async with self._device:
if preset_mode != self._preset_mode:
await self._device.send_command(PRESET_TO_COMMAND[preset_mode])
if preset_mode == PRESET_MODE_NORMAL:
await self._device.send_fan_speed(int(new_speed))
elif preset_mode == PRESET_MODE_AFTER_COOKING_MANUAL:
await self._device.send_after_cooking(int(new_speed))
elif preset_mode == PRESET_MODE_AFTER_COOKING_AUTO:
await self._device.send_after_cooking(0)
self.coordinator.async_set_updated_data(self._device.state)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self._device.send_command(PRESET_TO_COMMAND[preset_mode])
self.coordinator.async_set_updated_data(self._device.state)
async def async_turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
await self._device.send_command(COMMAND_STOP_FAN)
self.coordinator.async_set_updated_data(self._device.state)
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return len(ORDERED_NAMED_FAN_SPEEDS)
@property
def percentage(self) -> int | None:
"""Return the current speed."""
return self._percentage
@property
def supported_features(self) -> int:
"""Flag supported features."""
return SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE
@property
def is_on(self) -> bool:
"""Return true if fan is on."""
return self._percentage != 0
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self._preset_mode
@property
def preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
return PRESET_MODES
def _update_from_device_data(self, data: State | None) -> None:
"""Handle data update."""
if not data:
self._percentage = 0
return
if data.fan_speed:
self._percentage = ordered_list_item_to_percentage(
ORDERED_NAMED_FAN_SPEEDS, str(data.fan_speed)
)
else:
self._percentage = 0
if data.after_cooking_on:
if data.after_cooking_fan_speed:
self._preset_mode = PRESET_MODE_AFTER_COOKING_MANUAL
else:
self._preset_mode = PRESET_MODE_AFTER_COOKING_AUTO
else:
self._preset_mode = PRESET_MODE_NORMAL
@callback
def _handle_coordinator_update(self) -> None:
"""Handle data update."""
self._update_from_device_data(self.coordinator.data)
self.async_write_ha_state()

View File

@ -0,0 +1,13 @@
{
"domain": "fjaraskupan",
"name": "Fj\u00e4r\u00e5skupan",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
"requirements": [
"fjaraskupan==1.0.0"
],
"codeowners": [
"@elupus"
],
"iot_class": "local_polling"
}

View File

@ -0,0 +1,13 @@
{
"config": {
"step": {
"confirm": {
"description": "Do you want to set up Fjäråskupan?"
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
}
}

View File

@ -0,0 +1,13 @@
{
"config": {
"abort": {
"no_devices_found": "No devices found on the network",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"step": {
"confirm": {
"description": "Do you want to set up Fjäråskupan?"
}
}
}
}

View File

@ -77,6 +77,7 @@ FLOWS = [
"ezviz",
"faa_delays",
"fireservicerota",
"fjaraskupan",
"flick_electric",
"flipr",
"flo",

View File

@ -625,6 +625,9 @@ fitbit==0.3.1
# homeassistant.components.fixer
fixerio==1.0.0a0
# homeassistant.components.fjaraskupan
fjaraskupan==1.0.0
# homeassistant.components.flipr
flipr-api==1.4.1

View File

@ -345,6 +345,9 @@ faadelays==0.0.7
# homeassistant.components.feedreader
feedparser==6.0.2
# homeassistant.components.fjaraskupan
fjaraskupan==1.0.0
# homeassistant.components.flipr
flipr-api==1.4.1

View File

@ -0,0 +1 @@
"""Tests for the Fjäråskupan integration."""

View File

@ -0,0 +1,41 @@
"""Standard fixtures for the Fjäråskupan integration."""
from __future__ import annotations
from unittest.mock import patch
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, BaseBleakScanner
from pytest import fixture
@fixture(name="scanner", autouse=True)
def fixture_scanner(hass):
"""Fixture for scanner."""
devices = [BLEDevice("1.1.1.1", "COOKERHOOD_FJAR")]
class MockScanner(BaseBleakScanner):
"""Mock Scanner."""
async def start(self):
"""Start scanning for devices."""
for device in devices:
self._callback(device, AdvertisementData())
async def stop(self):
"""Stop scanning for devices."""
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return discovered devices."""
return devices
def set_scanning_filter(self, **kwargs):
"""Set the scanning filter."""
with patch(
"homeassistant.components.fjaraskupan.config_flow.BleakScanner", new=MockScanner
), patch(
"homeassistant.components.fjaraskupan.config_flow.CONST_WAIT_TIME", new=0.01
):
yield devices

View File

@ -0,0 +1,59 @@
"""Test the Fjäråskupan config flow."""
from __future__ import annotations
from unittest.mock import patch
from bleak.backends.device import BLEDevice
from pytest import fixture
from homeassistant import config_entries, setup
from homeassistant.components.fjaraskupan.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
@fixture(name="mock_setup_entry", autouse=True)
async def fixture_mock_setup_entry(hass):
"""Fixture for config entry."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.fjaraskupan.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Fjäråskupan"
assert result["data"] == {}
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_scan_no_devices(hass: HomeAssistant, scanner: list[BLEDevice]) -> None:
"""Test we get the form."""
scanner.clear()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "no_devices_found"