-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
593 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
"""The ac_infinity integration.""" | ||
from __future__ import annotations | ||
|
||
import asyncio | ||
from datetime import timedelta | ||
import logging | ||
|
||
import async_timeout | ||
from ac_infinity_ble import ACInfinityController, DeviceInfo | ||
|
||
from homeassistant.components import bluetooth | ||
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher | ||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import ( | ||
CONF_ADDRESS, | ||
CONF_SERVICE_DATA, | ||
EVENT_HOMEASSISTANT_STOP, | ||
Platform, | ||
) | ||
from homeassistant.core import Event, HomeAssistant, callback | ||
from homeassistant.exceptions import ConfigEntryNotReady | ||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
||
from .const import BLEAK_EXCEPTIONS, DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS | ||
from .models import ACInfinityData | ||
|
||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.FAN] | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up ac_infinity from a config entry.""" | ||
address: str = entry.data[CONF_ADDRESS] | ||
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) | ||
if not ble_device: | ||
raise ConfigEntryNotReady( | ||
f"Could not find AC Infinity device with address {address}" | ||
) | ||
|
||
controller = ACInfinityController( | ||
ble_device, DeviceInfo(**entry.data[CONF_SERVICE_DATA]) | ||
) | ||
|
||
@callback | ||
def _async_update_ble( | ||
service_info: bluetooth.BluetoothServiceInfoBleak, | ||
change: bluetooth.BluetoothChange, | ||
) -> None: | ||
"""Update from a ble callback.""" | ||
controller.set_ble_device_and_advertisement_data( | ||
service_info.device, service_info.advertisement | ||
) | ||
|
||
entry.async_on_unload( | ||
bluetooth.async_register_callback( | ||
hass, | ||
_async_update_ble, | ||
BluetoothCallbackMatcher({ADDRESS: address}), | ||
bluetooth.BluetoothScanningMode.PASSIVE, | ||
) | ||
) | ||
|
||
async def _async_update(): | ||
"""Update the device state.""" | ||
try: | ||
await controller.update() | ||
await controller.stop() | ||
except BLEAK_EXCEPTIONS as ex: | ||
raise UpdateFailed(str(ex)) from ex | ||
|
||
startup_event = asyncio.Event() | ||
cancel_first_update = controller.register_callback(lambda *_: startup_event.set()) | ||
coordinator = DataUpdateCoordinator( | ||
hass, | ||
_LOGGER, | ||
name=controller.name, | ||
update_method=_async_update, | ||
update_interval=timedelta(seconds=UPDATE_SECONDS), | ||
) | ||
|
||
try: | ||
await coordinator.async_config_entry_first_refresh() | ||
except ConfigEntryNotReady: | ||
cancel_first_update() | ||
raise | ||
|
||
try: | ||
async with async_timeout.timeout(DEVICE_TIMEOUT): | ||
await startup_event.wait() | ||
except asyncio.TimeoutError as ex: | ||
raise ConfigEntryNotReady( | ||
"Unable to communicate with the device; " | ||
f"Try moving the Bluetooth adapter closer to {controller.name}" | ||
) from ex | ||
finally: | ||
cancel_first_update() | ||
|
||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ACInfinityData( | ||
entry.title, controller, coordinator | ||
) | ||
|
||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) | ||
|
||
async def _async_stop(event: Event) -> None: | ||
"""Close the connection.""" | ||
try: | ||
await controller.stop() | ||
except BLEAK_EXCEPTIONS: | ||
pass | ||
|
||
entry.async_on_unload( | ||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) | ||
) | ||
return True | ||
|
||
|
||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: | ||
"""Handle options update.""" | ||
data: ACInfinityData = hass.data[DOMAIN][entry.entry_id] | ||
if entry.title != data.title: | ||
await hass.config_entries.async_reload(entry.entry_id) | ||
|
||
|
||
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: ACInfinityData = hass.data[DOMAIN].pop(entry.entry_id) | ||
try: | ||
await data.device.stop() | ||
except BLEAK_EXCEPTIONS: | ||
pass | ||
|
||
return unload_ok |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
"""Config flow for ac_infinity.""" | ||
from __future__ import annotations | ||
|
||
import logging | ||
from typing import Any | ||
|
||
from ac_infinity_ble import ACInfinityController, DeviceInfo | ||
from ac_infinity_ble.protocol import parse_manufacturer_data | ||
from ac_infinity_ble.const import MANUFACTURER_ID | ||
import voluptuous as vol | ||
|
||
from homeassistant import config_entries | ||
from homeassistant.components.bluetooth import ( | ||
BluetoothServiceInfoBleak, | ||
async_discovered_service_info, | ||
) | ||
from homeassistant.const import CONF_ADDRESS, CONF_SERVICE_DATA | ||
from homeassistant.data_entry_flow import FlowResult | ||
|
||
from .const import BLEAK_EXCEPTIONS, DOMAIN | ||
|
||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
"""Handle a config flow for AC Infinity Bluetooth.""" | ||
|
||
VERSION = 1 | ||
|
||
def __init__(self) -> None: | ||
"""Initialize the config flow.""" | ||
self._discovery_info: BluetoothServiceInfoBleak | None = None | ||
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} | ||
|
||
async def async_step_bluetooth( | ||
self, discovery_info: BluetoothServiceInfoBleak | ||
) -> FlowResult: | ||
"""Handle the bluetooth discovery step.""" | ||
await self.async_set_unique_id(discovery_info.address) | ||
self._abort_if_unique_id_configured() | ||
self._discovery_info = discovery_info | ||
device: DeviceInfo = parse_manufacturer_data( | ||
discovery_info.advertisement.manufacturer_data[MANUFACTURER_ID] | ||
) | ||
self.context["title_placeholders"] = {"name": device.name} | ||
return await self.async_step_user() | ||
|
||
async def async_step_user( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> FlowResult: | ||
"""Handle the user step to pick discovered device.""" | ||
errors: dict[str, str] = {} | ||
|
||
if user_input is not None: | ||
address = user_input[CONF_ADDRESS] | ||
discovery_info = self._discovered_devices[address] | ||
await self.async_set_unique_id( | ||
discovery_info.address, raise_on_progress=False | ||
) | ||
self._abort_if_unique_id_configured() | ||
controller = ACInfinityController( | ||
discovery_info.device, advertisement_data=discovery_info.advertisement | ||
) | ||
try: | ||
await controller.update() | ||
except BLEAK_EXCEPTIONS: | ||
errors["base"] = "cannot_connect" | ||
except Exception: # pylint: disable=broad-except | ||
_LOGGER.exception("Unexpected error") | ||
errors["base"] = "unknown" | ||
else: | ||
await controller.stop() | ||
return self.async_create_entry( | ||
title=controller.name, | ||
data={ | ||
CONF_ADDRESS: discovery_info.address, | ||
CONF_SERVICE_DATA: parse_manufacturer_data( | ||
discovery_info.advertisement.manufacturer_data[ | ||
MANUFACTURER_ID | ||
] | ||
), | ||
}, | ||
) | ||
|
||
if discovery := self._discovery_info: | ||
self._discovered_devices[discovery.address] = discovery | ||
else: | ||
current_addresses = self._async_current_ids() | ||
for discovery in async_discovered_service_info(self.hass): | ||
if ( | ||
discovery.address in current_addresses | ||
or discovery.address in self._discovered_devices | ||
): | ||
continue | ||
self._discovered_devices[discovery.address] = discovery | ||
|
||
if not self._discovered_devices: | ||
return self.async_abort(reason="no_devices_found") | ||
|
||
devices = {} | ||
for service_info in self._discovered_devices.values(): | ||
device = parse_manufacturer_data( | ||
service_info.advertisement.manufacturer_data[MANUFACTURER_ID] | ||
) | ||
devices[service_info.address] = f"{device.name} ({service_info.address})" | ||
|
||
data_schema = vol.Schema( | ||
{ | ||
vol.Required(CONF_ADDRESS): vol.In(devices), | ||
} | ||
) | ||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=data_schema, | ||
errors=errors, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
"""Constants for the ac_infinity integration.""" | ||
from bleak.exc import BleakError | ||
|
||
DOMAIN = "ac_infinity" | ||
|
||
DEVICE_TIMEOUT = 30 | ||
UPDATE_SECONDS = 15 | ||
|
||
BLEAK_EXCEPTIONS = (AttributeError, BleakError) | ||
|
||
DEVICE_MODEL = {1: "Controller 67", 7: "Controller 69", 11: "Controller 69 Pro"} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
"""The ac_infinity fan platform.""" | ||
from __future__ import annotations | ||
|
||
import math | ||
from typing import Any | ||
|
||
from ac_infinity_ble import ACInfinityController | ||
|
||
from homeassistant.components.fan import FanEntity, FanEntityFeature | ||
|
||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.core import HomeAssistant, callback | ||
from homeassistant.helpers import device_registry as dr | ||
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 ( | ||
int_states_in_range, | ||
ranged_value_to_percentage, | ||
percentage_to_ranged_value, | ||
) | ||
|
||
from .const import DEVICE_MODEL, DOMAIN | ||
from .models import ACInfinityData | ||
|
||
SPEED_RANGE = (1, 10) | ||
|
||
|
||
async def async_setup_entry( | ||
hass: HomeAssistant, | ||
entry: ConfigEntry, | ||
async_add_entities: AddEntitiesCallback, | ||
) -> None: | ||
"""Set up the light platform for LEDBLE.""" | ||
data: ACInfinityData = hass.data[DOMAIN][entry.entry_id] | ||
async_add_entities([ACInfinityFan(data.coordinator, data.device, entry.title)]) | ||
|
||
|
||
class ACInfinityFan(CoordinatorEntity, FanEntity): | ||
"""Representation of AC Infinity sensor.""" | ||
|
||
_attr_speed_count = int_states_in_range(SPEED_RANGE) | ||
_attr_supported_features = FanEntityFeature.SET_SPEED | ||
|
||
def __init__( | ||
self, | ||
coordinator: DataUpdateCoordinator, | ||
device: ACInfinityController, | ||
name: str, | ||
) -> None: | ||
"""Initialize an AC Infinity sensor.""" | ||
super().__init__(coordinator) | ||
self._device = device | ||
self._attr_name = f"{name} Fan" | ||
self._attr_unique_id = f"{self._device.address}_fan" | ||
self._attr_device_info = DeviceInfo( | ||
name=device.name, | ||
model=DEVICE_MODEL[device.state.type], | ||
manufacturer="AC Infinity", | ||
sw_version=device.state.version, | ||
connections={(dr.CONNECTION_BLUETOOTH, device.address)}, | ||
) | ||
self._async_update_attrs() | ||
|
||
async def async_set_percentage(self, percentage: int) -> None: | ||
"""Set the speed of the fan, as a percentage.""" | ||
speed = 0 | ||
if percentage > 0: | ||
speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) | ||
|
||
await self._device.set_speed(speed) | ||
self._async_update_attrs() | ||
|
||
async def async_turn_on( | ||
self, | ||
percentage: int | None = None, | ||
preset_mode: str | None = None, | ||
**kwargs: Any, | ||
) -> None: | ||
"""Turn on the fan.""" | ||
speed = None | ||
if percentage is not None: | ||
speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) | ||
await self._device.turn_on(speed) | ||
self._async_update_attrs() | ||
|
||
async def async_turn_off(self, **kwargs: Any) -> None: | ||
"""Turn off the fan.""" | ||
await self._device.turn_off() | ||
self._async_update_attrs() | ||
|
||
@callback | ||
def _async_update_attrs(self) -> None: | ||
"""Handle updating _attr values.""" | ||
self._attr_is_on = self._device.is_on | ||
self._attr_percentage = ranged_value_to_percentage( | ||
SPEED_RANGE, self._device.state.fan | ||
) | ||
|
||
@callback | ||
def _handle_coordinator_update(self, *args: Any) -> None: | ||
"""Handle data update.""" | ||
self._async_update_attrs() | ||
self.async_write_ha_state() | ||
|
||
async def async_added_to_hass(self) -> None: | ||
"""Register callbacks.""" | ||
self.async_on_remove( | ||
self._device.register_callback(self._handle_coordinator_update) | ||
) | ||
return await super().async_added_to_hass() |
Oops, something went wrong.