Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
hunterjm committed Jul 15, 2023
1 parent ec14c13 commit 7203ad9
Show file tree
Hide file tree
Showing 10 changed files with 593 additions and 0 deletions.
135 changes: 135 additions & 0 deletions custom_components/ac_infinity/__init__.py
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
117 changes: 117 additions & 0 deletions custom_components/ac_infinity/config_flow.py
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,
)
11 changes: 11 additions & 0 deletions custom_components/ac_infinity/const.py
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"}
114 changes: 114 additions & 0 deletions custom_components/ac_infinity/fan.py
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()
Loading

0 comments on commit 7203ad9

Please sign in to comment.