Add Big Ass Fans integration (#71498)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2022-05-14 17:22:47 -05:00 committed by GitHub
parent 0584e84c30
commit 51c6a68036
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 611 additions and 0 deletions

View File

@ -93,6 +93,9 @@ omit =
homeassistant/components/azure_devops/const.py
homeassistant/components/azure_devops/sensor.py
homeassistant/components/azure_service_bus/*
homeassistant/components/baf/__init__.py
homeassistant/components/baf/entity.py
homeassistant/components/baf/fan.py
homeassistant/components/baidu/tts.py
homeassistant/components/balboa/__init__.py
homeassistant/components/beewi_smartclim/sensor.py

View File

@ -55,6 +55,7 @@ homeassistant.components.aseko_pool_live.*
homeassistant.components.asuswrt.*
homeassistant.components.automation.*
homeassistant.components.backup.*
homeassistant.components.baf.*
homeassistant.components.binary_sensor.*
homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.*

View File

@ -120,6 +120,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/azure_service_bus/ @hfurubotten
/homeassistant/components/backup/ @home-assistant/core
/tests/components/backup/ @home-assistant/core
/homeassistant/components/baf/ @bdraco @jfroy
/tests/components/baf/ @bdraco @jfroy
/homeassistant/components/balboa/ @garbled1
/tests/components/balboa/ @garbled1
/homeassistant/components/beewi_smartclim/ @alemuro

View File

@ -0,0 +1,46 @@
"""The Big Ass Fans integration."""
from __future__ import annotations
import asyncio
from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, QUERY_INTERVAL, RUN_TIMEOUT
from .models import BAFData
PLATFORMS: list[Platform] = [Platform.FAN]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Big Ass Fans from a config entry."""
ip_address = entry.data[CONF_IP_ADDRESS]
service = Service(ip_addresses=[ip_address], uuid=entry.unique_id, port=PORT)
device = Device(service, query_interval_seconds=QUERY_INTERVAL)
run_future = device.async_run()
try:
await asyncio.wait_for(device.async_wait_available(), timeout=RUN_TIMEOUT)
except asyncio.TimeoutError as ex:
run_future.cancel()
raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BAFData(device, run_future)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
data: BAFData = hass.data[DOMAIN].pop(entry.entry_id)
data.run_future.cancel()
return unload_ok

View File

@ -0,0 +1,120 @@
"""Config flow for baf."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.data_entry_flow import FlowResult
from homeassistant.util.network import is_ipv6_address
from .const import DOMAIN, RUN_TIMEOUT
from .models import BAFDiscovery
_LOGGER = logging.getLogger(__name__)
async def async_try_connect(ip_address: str) -> Device:
"""Validate we can connect to a device."""
device = Device(Service(ip_addresses=[ip_address], port=PORT))
run_future = device.async_run()
try:
await asyncio.wait_for(device.async_wait_available(), timeout=RUN_TIMEOUT)
except asyncio.TimeoutError as ex:
raise CannotConnect from ex
finally:
run_future.cancel()
return device
class BAFFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle BAF discovery config flow."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the BAF config flow."""
self.discovery: BAFDiscovery | None = None
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
"""Handle zeroconf discovery."""
properties = discovery_info.properties
ip_address = discovery_info.host
if is_ipv6_address(ip_address):
return self.async_abort(reason="ipv6_not_supported")
uuid = properties["uuid"]
model = properties["model"]
name = properties["name"]
await self.async_set_unique_id(uuid, raise_on_progress=False)
self._abort_if_unique_id_configured(updates={CONF_IP_ADDRESS: ip_address})
self.discovery = BAFDiscovery(ip_address, name, uuid, model)
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self.discovery is not None
discovery = self.discovery
if user_input is not None:
return self.async_create_entry(
title=discovery.name,
data={CONF_IP_ADDRESS: discovery.ip_address},
)
placeholders = {
"name": discovery.name,
"model": discovery.model,
"ip_address": discovery.ip_address,
}
self.context["title_placeholders"] = placeholders
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm", description_placeholders=placeholders
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
ip_address = (user_input or {}).get(CONF_IP_ADDRESS, "")
if user_input is not None:
try:
device = await async_try_connect(ip_address)
except CannotConnect:
errors[CONF_IP_ADDRESS] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unknown exception during connection test to %s", ip_address
)
errors["base"] = "unknown"
else:
await self.async_set_unique_id(device.dns_sd_uuid)
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: ip_address}
)
return self.async_create_entry(
title=device.name,
data={CONF_IP_ADDRESS: ip_address},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_IP_ADDRESS, default=ip_address): str}
),
errors=errors,
)
class CannotConnect(Exception):
"""Exception to raise when we cannot connect."""

View File

@ -0,0 +1,19 @@
"""Constants for the Big Ass Fans integration."""
DOMAIN = "baf"
# Most properties are pushed, only the
# query every 5 minutes so we keep the RPM
# sensors up to date
QUERY_INTERVAL = 300
RUN_TIMEOUT = 20
PRESET_MODE_AUTO = "Auto"
SPEED_COUNT = 7
SPEED_RANGE = (1, SPEED_COUNT)
ONE_MIN_SECS = 60
ONE_DAY_SECS = 86400
HALF_DAY_SECS = 43200

View File

@ -0,0 +1,48 @@
"""The baf integration entities."""
from __future__ import annotations
from aiobafi6 import Device
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity import DeviceInfo, Entity
class BAFEntity(Entity):
"""Base class for baf entities."""
_attr_should_poll = False
def __init__(self, device: Device, name: str) -> None:
"""Initialize the entity."""
self._device = device
self._attr_unique_id = format_mac(self._device.mac_address)
self._attr_name = name
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac_address)},
name=self._device.name,
manufacturer="Big Ass Fans",
model=self._device.model,
sw_version=self._device.firmware_version,
)
self._async_update_attrs()
@callback
def _async_update_attrs(self) -> None:
"""Update attrs from device."""
self._attr_available = self._device.available
@callback
def _async_update_from_device(self, device: Device) -> None:
"""Process an update from the device."""
self._async_update_attrs()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Add data updated listener after this object has been initialized."""
self._device.add_callback(self._async_update_from_device)
async def async_will_remove_from_hass(self) -> None:
"""Remove data updated listener after this object has been initialized."""
self._device.remove_callback(self._async_update_from_device)

View File

@ -0,0 +1,97 @@
"""Support for Big Ass Fans fan."""
from __future__ import annotations
import math
from typing import Any
from aiobafi6 import OffOnAuto
from homeassistant import config_entries
from homeassistant.components.fan import (
DIRECTION_FORWARD,
DIRECTION_REVERSE,
FanEntity,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from .const import DOMAIN, PRESET_MODE_AUTO, SPEED_COUNT, SPEED_RANGE
from .entity import BAFEntity
from .models import BAFData
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up SenseME fans."""
data: BAFData = hass.data[DOMAIN][entry.entry_id]
if data.device.has_fan:
async_add_entities([BAFFan(data.device, data.device.name)])
class BAFFan(BAFEntity, FanEntity):
"""BAF ceiling fan component."""
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION
_attr_preset_modes = [PRESET_MODE_AUTO]
_attr_speed_count = SPEED_COUNT
@callback
def _async_update_attrs(self) -> None:
"""Update attrs from device."""
self._attr_is_on = self._device.fan_mode == OffOnAuto.ON
self._attr_current_direction = DIRECTION_FORWARD
if self._device.reverse_enable:
self._attr_current_direction = DIRECTION_REVERSE
if self._device.speed is not None:
self._attr_percentage = ranged_value_to_percentage(
SPEED_RANGE, self._device.speed
)
else:
self._attr_percentage = None
auto = self._device.fan_mode == OffOnAuto.AUTO
self._attr_preset_mode = PRESET_MODE_AUTO if auto else None
super()._async_update_attrs()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
device = self._device
if device.fan_mode != OffOnAuto.ON:
device.fan_mode = OffOnAuto.ON
device.speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on with a percentage or preset mode."""
if preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
return
if percentage is None:
self._device.fan_mode = OffOnAuto.ON
return
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
self._device.fan_mode = OffOnAuto.OFF
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode != PRESET_MODE_AUTO:
raise ValueError(f"Invalid preset mode: {preset_mode}")
self._device.fan_mode = OffOnAuto.AUTO
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
self._device.reverse_enable = direction == DIRECTION_REVERSE

View File

@ -0,0 +1,13 @@
{
"domain": "baf",
"name": "Big Ass Fans",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/baf",
"requirements": ["aiobafi6==0.3.0"],
"codeowners": ["@bdraco", "@jfroy"],
"iot_class": "local_push",
"zeroconf": [
{ "type": "_api._tcp.local.", "properties": { "model": "haiku*" } },
{ "type": "_api._tcp.local.", "properties": { "model": "i6*" } }
]
}

View File

@ -0,0 +1,25 @@
"""The baf integration models."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from aiobafi6 import Device
@dataclass
class BAFData:
"""Data for the baf integration."""
device: Device
run_future: asyncio.Future
@dataclass
class BAFDiscovery:
"""A BAF Discovery."""
ip_address: str
name: str
uuid: str
model: str

View File

@ -0,0 +1,23 @@
{
"config": {
"flow_title": "{name} - {model} ({ip_address})",
"step": {
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
}
},
"discovery_confirm": {
"description": "Do you want to setup {name} - {model} ({ip_address})?"
}
},
"abort": {
"ipv6_not_supported": "IPv6 is not supported.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@ -0,0 +1,23 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"ipv6_not_supported": "IPv6 is not supported."
},
"error": {
"cannot_connect": "Failed to connect",
"unknown": "Unexpected error"
},
"flow_title": "{name} - {model} ({ip_address})",
"step": {
"discovery_confirm": {
"description": "Do you want to setup {name} - {model} ({ip_address})?"
},
"user": {
"data": {
"ip_address": "IP Address"
}
}
}
}
}

View File

@ -42,6 +42,7 @@ FLOWS = {
"axis",
"azure_devops",
"azure_event_hub",
"baf",
"balboa",
"blebox",
"blink",

View File

@ -42,6 +42,20 @@ ZEROCONF = {
"domain": "apple_tv"
}
],
"_api._tcp.local.": [
{
"domain": "baf",
"properties": {
"model": "haiku*"
}
},
{
"domain": "baf",
"properties": {
"model": "i6*"
}
}
],
"_api._udp.local.": [
{
"domain": "guardian"

View File

@ -368,6 +368,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.baf.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.binary_sensor.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -121,6 +121,9 @@ aioasuswrt==1.4.0
# homeassistant.components.azure_devops
aioazuredevops==1.3.5
# homeassistant.components.baf
aiobafi6==0.3.0
# homeassistant.components.aws
aiobotocore==2.1.0

View File

@ -108,6 +108,9 @@ aioasuswrt==1.4.0
# homeassistant.components.azure_devops
aioazuredevops==1.3.5
# homeassistant.components.baf
aiobafi6==0.3.0
# homeassistant.components.aws
aiobotocore==2.1.0

View File

@ -0,0 +1 @@
"""Tests for the Big Ass Fans integration."""

View File

@ -0,0 +1,158 @@
"""Test the baf config flow."""
import asyncio
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.baf.const import DOMAIN
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from tests.common import MockConfigEntry
async def test_form_user(hass):
"""Test we get the user form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch("homeassistant.components.baf.config_flow.Device.async_run",), patch(
"homeassistant.components.baf.config_flow.Device.async_wait_available",
), patch(
"homeassistant.components.baf.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_IP_ADDRESS: "127.0.0.1"},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "127.0.0.1"
assert result2["data"] == {CONF_IP_ADDRESS: "127.0.0.1"}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch("homeassistant.components.baf.config_flow.Device.async_run",), patch(
"homeassistant.components.baf.config_flow.Device.async_wait_available",
side_effect=asyncio.TimeoutError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_IP_ADDRESS: "127.0.0.1"},
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"}
async def test_form_unknown_exception(hass):
"""Test we handle unknown exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch("homeassistant.components.baf.config_flow.Device.async_run",), patch(
"homeassistant.components.baf.config_flow.Device.async_wait_available",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_IP_ADDRESS: "127.0.0.1"},
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "unknown"}
async def test_zeroconf_discovery(hass):
"""Test we can setup from zeroconf discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="127.0.0.1",
addresses=["127.0.0.1"],
hostname="mock_hostname",
name="testfan",
port=None,
properties={"name": "My Fan", "model": "Haiku", "uuid": "1234"},
type="mock_type",
),
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
with patch(
"homeassistant.components.baf.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "My Fan"
assert result2["data"] == {CONF_IP_ADDRESS: "127.0.0.1"}
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_updates_existing_ip(hass):
"""Test we can setup from zeroconf discovery."""
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_IP_ADDRESS: "127.0.0.2"}, unique_id="1234"
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="127.0.0.1",
addresses=["127.0.0.1"],
hostname="mock_hostname",
name="testfan",
port=None,
properties={"name": "My Fan", "model": "Haiku", "uuid": "1234"},
type="mock_type",
),
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_IP_ADDRESS] == "127.0.0.1"
async def test_zeroconf_rejects_ipv6(hass):
"""Test zeroconf discovery rejects ipv6."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="fd00::b27c:63bb:cc85:4ea0",
addresses=["fd00::b27c:63bb:cc85:4ea0"],
hostname="mock_hostname",
name="testfan",
port=None,
properties={"name": "My Fan", "model": "Haiku", "uuid": "1234"},
type="mock_type",
),
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "ipv6_not_supported"