Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Big Ass Fans integration #71498

Merged
merged 37 commits into from
May 14, 2022
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2d30616
baf
bdraco May 6, 2022
c478cb7
wip
bdraco May 6, 2022
d0edbc3
add discovery
bdraco May 6, 2022
2a76682
tweaks
bdraco May 6, 2022
4775cf3
fixes
bdraco May 6, 2022
012bc48
tweak
bdraco May 6, 2022
35387cc
tweaks
bdraco May 6, 2022
ca0162b
tweaks
bdraco May 6, 2022
28f8644
add manual setup
bdraco May 7, 2022
fa4a880
add manual setup
bdraco May 7, 2022
6c7975b
strict typing
bdraco May 7, 2022
0a2e1a0
adjust
bdraco May 7, 2022
b95bc2f
Add support for lights
bdraco May 7, 2022
106630e
Move auto comfort to the climate platform
bdraco May 7, 2022
c098931
Move auto comfort to the climate platform
bdraco May 7, 2022
2d8ff9e
Add sensor platform
bdraco May 7, 2022
7c4830a
add workaround
bdraco May 7, 2022
8c959e5
tweaks
bdraco May 7, 2022
bfaba13
number platform
bdraco May 7, 2022
9796eac
tweak names
bdraco May 7, 2022
375d236
tweaks
bdraco May 7, 2022
46612d0
try to restore last speed
bdraco May 7, 2022
6effdb7
drop default
bdraco May 7, 2022
769937f
add cover
bdraco May 7, 2022
819255d
remove
bdraco May 7, 2022
9cd471a
add tests
bdraco May 7, 2022
63e027b
bump lib for upstream fixes, prune
bdraco May 7, 2022
09e715a
preen
bdraco May 7, 2022
9e57686
no cover for fan yet
bdraco May 7, 2022
dfa64d6
merge
bdraco May 8, 2022
d66cf2e
remove workarounds
bdraco May 8, 2022
448d241
adapt
bdraco May 8, 2022
776fb30
Merge branch 'dev' into baf_merge_1
bdraco May 13, 2022
b499517
Update homeassistant/components/baf/strings.json
bdraco May 14, 2022
65c1798
fix copied code
bdraco May 14, 2022
da45340
Add ipv6 guard
bdraco May 14, 2022
dc01716
cover
bdraco May 14, 2022
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
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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
Expand Down
54 changes: 54 additions & 0 deletions homeassistant/components/baf/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""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_task = device.run()

try:
# Wait available doesn't mean the name is actually filled in yet
# so sometimes the device will show up without a name
await asyncio.wait_for(device.async_wait_available(), timeout=RUN_TIMEOUT)
except asyncio.TimeoutError as ex:
run_task.cancel()
raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex

# Temporary workaround until the upstream lib is fixed
# to ensure we get the name
for _ in range(50):
if not device.name:
await asyncio.sleep(0.1)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BAFData(device, run_task)
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_task.cancel()

return unload_ok
118 changes: 118 additions & 0 deletions homeassistant/components/baf/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""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 .const import DOMAIN, RUN_TIMEOUT
from .models import BAFDiscovery

API_SUFFIX = "._api._tcp.local."
_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_task = device.run()
try:
await asyncio.wait_for(device.async_wait_available(), timeout=RUN_TIMEOUT)
except asyncio.TimeoutError as ex:
raise CannotConnect from ex
finally:
run_task.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
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."""
28 changes: 28 additions & 0 deletions homeassistant/components/baf/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Constants for the Big Ass Fans integration."""
from enum import IntEnum

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


class OffOnAuto(IntEnum):
"""Tri-state mode enum that matches the protocol buffer."""

OFF = 0
ON = 1
AUTO = 2
48 changes: 48 additions & 0 deletions homeassistant/components/baf/entity.py
Original file line number Diff line number Diff line change
@@ -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)
103 changes: 103 additions & 0 deletions homeassistant/components/baf/fan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Support for Big Ass Fans fan."""
from __future__ import annotations

import math
from typing import Any

from aiobafi6 import Device

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, OffOnAuto
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]
device = data.device
if device.has_fan:
async_add_entities([BAFFan(device)])


class BAFFan(BAFEntity, FanEntity):
"""BAF ceiling fan component."""

_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION
_attr_preset_modes = [PRESET_MODE_AUTO]

def __init__(self, device: Device) -> None:
"""Initialize the entity."""
super().__init__(device, device.name)
self._attr_speed_count = SPEED_COUNT

@callback
def _async_update_attrs(self) -> None:
"""Update attrs from device."""
device = self._device
self._attr_is_on = device.fan_mode == OffOnAuto.ON
self._attr_current_direction = DIRECTION_FORWARD
if 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 = 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
13 changes: 13 additions & 0 deletions homeassistant/components/baf/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "baf",
"name": "Big Ass Fans",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/baf",
"requirements": ["aiobafi6==0.1.0"],
"codeowners": ["@bdraco", "@jfroy"],
"iot_class": "local_push",
"zeroconf": [
{ "type": "_api._tcp.local.", "properties": { "model": "haiku*" } },
{ "type": "_api._tcp.local.", "properties": { "model": "i6*" } }
]
}
Loading