diff --git a/.coveragerc b/.coveragerc index 237e676c9ac2256ad73b4605915be8d93d89b6d0..9c27fd4c7289c7d2221de338389a386a639d3207 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 6c6a248cdc9790bad6a0904b3daf23590b239a53..62b5b70648bb2e1369e0fd41456a58b28f16e469 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..598fefe30c834df829de2f5c4a9be8148fa2fe66 --- /dev/null +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -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 diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..9b82ae1199bd99c17e3956b6b97db9babeba4e31 --- /dev/null +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -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) diff --git a/homeassistant/components/fjaraskupan/const.py b/homeassistant/components/fjaraskupan/const.py new file mode 100644 index 0000000000000000000000000000000000000000..957ac518293afcb0db13511e055639cea27f001a --- /dev/null +++ b/homeassistant/components/fjaraskupan/const.py @@ -0,0 +1,5 @@ +"""Constants for the Fjäråskupan integration.""" + +DOMAIN = "fjaraskupan" + +DISPATCH_DETECTION = f"{DOMAIN}.detection" diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py new file mode 100644 index 0000000000000000000000000000000000000000..4a81e70b848124befc0b11c74b2406f3e144b0d8 --- /dev/null +++ b/homeassistant/components/fjaraskupan/fan.py @@ -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() diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..68158776afe2638fc5dc5b09b6ec10de26f2706f --- /dev/null +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/strings.json b/homeassistant/components/fjaraskupan/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..c72fc777772b6ea0dab2b8c89a048a18ea74492e --- /dev/null +++ b/homeassistant/components/fjaraskupan/strings.json @@ -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%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/en.json b/homeassistant/components/fjaraskupan/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..206d0c9cbdb7116adf12a2b6a7781033ec6d7234 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/en.json @@ -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?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 339bbb1ede3a648ce3359e6ce2d8a5e384ab2773..3d43e4cbccb18dca9af03e6c545703ccab0acba7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = [ "ezviz", "faa_delays", "fireservicerota", + "fjaraskupan", "flick_electric", "flipr", "flo", diff --git a/requirements_all.txt b/requirements_all.txt index 8ddac2bd76bcd7217b0f9a8f535d83450e31573b..a1b9869e1261c503fe08c113b6e58f6dbdddc156 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a03aaca96f16f36699e439131e16701aa1dde7f..cacc3ed3f26667a81326cad8ade5711d11872e65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..26a5ecd660590a9b49e74e829ab5aefffbaf69e4 --- /dev/null +++ b/tests/components/fjaraskupan/__init__.py @@ -0,0 +1 @@ +"""Tests for the Fjäråskupan integration.""" diff --git a/tests/components/fjaraskupan/conftest.py b/tests/components/fjaraskupan/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..d60abcdb9ad35faa7ad70ac4e1fbc41dbe7a5f1d --- /dev/null +++ b/tests/components/fjaraskupan/conftest.py @@ -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 diff --git a/tests/components/fjaraskupan/test_config_flow.py b/tests/components/fjaraskupan/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..7244042d35666538701faac37387ea08853ac507 --- /dev/null +++ b/tests/components/fjaraskupan/test_config_flow.py @@ -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"