Skip to content

Commit 7203ad9

Browse files
committed
initial commit
1 parent ec14c13 commit 7203ad9

File tree

10 files changed

+593
-0
lines changed

10 files changed

+593
-0
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""The ac_infinity integration."""
2+
from __future__ import annotations
3+
4+
import asyncio
5+
from datetime import timedelta
6+
import logging
7+
8+
import async_timeout
9+
from ac_infinity_ble import ACInfinityController, DeviceInfo
10+
11+
from homeassistant.components import bluetooth
12+
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.const import (
15+
CONF_ADDRESS,
16+
CONF_SERVICE_DATA,
17+
EVENT_HOMEASSISTANT_STOP,
18+
Platform,
19+
)
20+
from homeassistant.core import Event, HomeAssistant, callback
21+
from homeassistant.exceptions import ConfigEntryNotReady
22+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
23+
24+
from .const import BLEAK_EXCEPTIONS, DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS
25+
from .models import ACInfinityData
26+
27+
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.FAN]
28+
29+
_LOGGER = logging.getLogger(__name__)
30+
31+
32+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
33+
"""Set up ac_infinity from a config entry."""
34+
address: str = entry.data[CONF_ADDRESS]
35+
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
36+
if not ble_device:
37+
raise ConfigEntryNotReady(
38+
f"Could not find AC Infinity device with address {address}"
39+
)
40+
41+
controller = ACInfinityController(
42+
ble_device, DeviceInfo(**entry.data[CONF_SERVICE_DATA])
43+
)
44+
45+
@callback
46+
def _async_update_ble(
47+
service_info: bluetooth.BluetoothServiceInfoBleak,
48+
change: bluetooth.BluetoothChange,
49+
) -> None:
50+
"""Update from a ble callback."""
51+
controller.set_ble_device_and_advertisement_data(
52+
service_info.device, service_info.advertisement
53+
)
54+
55+
entry.async_on_unload(
56+
bluetooth.async_register_callback(
57+
hass,
58+
_async_update_ble,
59+
BluetoothCallbackMatcher({ADDRESS: address}),
60+
bluetooth.BluetoothScanningMode.PASSIVE,
61+
)
62+
)
63+
64+
async def _async_update():
65+
"""Update the device state."""
66+
try:
67+
await controller.update()
68+
await controller.stop()
69+
except BLEAK_EXCEPTIONS as ex:
70+
raise UpdateFailed(str(ex)) from ex
71+
72+
startup_event = asyncio.Event()
73+
cancel_first_update = controller.register_callback(lambda *_: startup_event.set())
74+
coordinator = DataUpdateCoordinator(
75+
hass,
76+
_LOGGER,
77+
name=controller.name,
78+
update_method=_async_update,
79+
update_interval=timedelta(seconds=UPDATE_SECONDS),
80+
)
81+
82+
try:
83+
await coordinator.async_config_entry_first_refresh()
84+
except ConfigEntryNotReady:
85+
cancel_first_update()
86+
raise
87+
88+
try:
89+
async with async_timeout.timeout(DEVICE_TIMEOUT):
90+
await startup_event.wait()
91+
except asyncio.TimeoutError as ex:
92+
raise ConfigEntryNotReady(
93+
"Unable to communicate with the device; "
94+
f"Try moving the Bluetooth adapter closer to {controller.name}"
95+
) from ex
96+
finally:
97+
cancel_first_update()
98+
99+
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ACInfinityData(
100+
entry.title, controller, coordinator
101+
)
102+
103+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
104+
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
105+
106+
async def _async_stop(event: Event) -> None:
107+
"""Close the connection."""
108+
try:
109+
await controller.stop()
110+
except BLEAK_EXCEPTIONS:
111+
pass
112+
113+
entry.async_on_unload(
114+
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
115+
)
116+
return True
117+
118+
119+
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
120+
"""Handle options update."""
121+
data: ACInfinityData = hass.data[DOMAIN][entry.entry_id]
122+
if entry.title != data.title:
123+
await hass.config_entries.async_reload(entry.entry_id)
124+
125+
126+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
127+
"""Unload a config entry."""
128+
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
129+
data: ACInfinityData = hass.data[DOMAIN].pop(entry.entry_id)
130+
try:
131+
await data.device.stop()
132+
except BLEAK_EXCEPTIONS:
133+
pass
134+
135+
return unload_ok
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Config flow for ac_infinity."""
2+
from __future__ import annotations
3+
4+
import logging
5+
from typing import Any
6+
7+
from ac_infinity_ble import ACInfinityController, DeviceInfo
8+
from ac_infinity_ble.protocol import parse_manufacturer_data
9+
from ac_infinity_ble.const import MANUFACTURER_ID
10+
import voluptuous as vol
11+
12+
from homeassistant import config_entries
13+
from homeassistant.components.bluetooth import (
14+
BluetoothServiceInfoBleak,
15+
async_discovered_service_info,
16+
)
17+
from homeassistant.const import CONF_ADDRESS, CONF_SERVICE_DATA
18+
from homeassistant.data_entry_flow import FlowResult
19+
20+
from .const import BLEAK_EXCEPTIONS, DOMAIN
21+
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
27+
"""Handle a config flow for AC Infinity Bluetooth."""
28+
29+
VERSION = 1
30+
31+
def __init__(self) -> None:
32+
"""Initialize the config flow."""
33+
self._discovery_info: BluetoothServiceInfoBleak | None = None
34+
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
35+
36+
async def async_step_bluetooth(
37+
self, discovery_info: BluetoothServiceInfoBleak
38+
) -> FlowResult:
39+
"""Handle the bluetooth discovery step."""
40+
await self.async_set_unique_id(discovery_info.address)
41+
self._abort_if_unique_id_configured()
42+
self._discovery_info = discovery_info
43+
device: DeviceInfo = parse_manufacturer_data(
44+
discovery_info.advertisement.manufacturer_data[MANUFACTURER_ID]
45+
)
46+
self.context["title_placeholders"] = {"name": device.name}
47+
return await self.async_step_user()
48+
49+
async def async_step_user(
50+
self, user_input: dict[str, Any] | None = None
51+
) -> FlowResult:
52+
"""Handle the user step to pick discovered device."""
53+
errors: dict[str, str] = {}
54+
55+
if user_input is not None:
56+
address = user_input[CONF_ADDRESS]
57+
discovery_info = self._discovered_devices[address]
58+
await self.async_set_unique_id(
59+
discovery_info.address, raise_on_progress=False
60+
)
61+
self._abort_if_unique_id_configured()
62+
controller = ACInfinityController(
63+
discovery_info.device, advertisement_data=discovery_info.advertisement
64+
)
65+
try:
66+
await controller.update()
67+
except BLEAK_EXCEPTIONS:
68+
errors["base"] = "cannot_connect"
69+
except Exception: # pylint: disable=broad-except
70+
_LOGGER.exception("Unexpected error")
71+
errors["base"] = "unknown"
72+
else:
73+
await controller.stop()
74+
return self.async_create_entry(
75+
title=controller.name,
76+
data={
77+
CONF_ADDRESS: discovery_info.address,
78+
CONF_SERVICE_DATA: parse_manufacturer_data(
79+
discovery_info.advertisement.manufacturer_data[
80+
MANUFACTURER_ID
81+
]
82+
),
83+
},
84+
)
85+
86+
if discovery := self._discovery_info:
87+
self._discovered_devices[discovery.address] = discovery
88+
else:
89+
current_addresses = self._async_current_ids()
90+
for discovery in async_discovered_service_info(self.hass):
91+
if (
92+
discovery.address in current_addresses
93+
or discovery.address in self._discovered_devices
94+
):
95+
continue
96+
self._discovered_devices[discovery.address] = discovery
97+
98+
if not self._discovered_devices:
99+
return self.async_abort(reason="no_devices_found")
100+
101+
devices = {}
102+
for service_info in self._discovered_devices.values():
103+
device = parse_manufacturer_data(
104+
service_info.advertisement.manufacturer_data[MANUFACTURER_ID]
105+
)
106+
devices[service_info.address] = f"{device.name} ({service_info.address})"
107+
108+
data_schema = vol.Schema(
109+
{
110+
vol.Required(CONF_ADDRESS): vol.In(devices),
111+
}
112+
)
113+
return self.async_show_form(
114+
step_id="user",
115+
data_schema=data_schema,
116+
errors=errors,
117+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Constants for the ac_infinity integration."""
2+
from bleak.exc import BleakError
3+
4+
DOMAIN = "ac_infinity"
5+
6+
DEVICE_TIMEOUT = 30
7+
UPDATE_SECONDS = 15
8+
9+
BLEAK_EXCEPTIONS = (AttributeError, BleakError)
10+
11+
DEVICE_MODEL = {1: "Controller 67", 7: "Controller 69", 11: "Controller 69 Pro"}

custom_components/ac_infinity/fan.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""The ac_infinity fan platform."""
2+
from __future__ import annotations
3+
4+
import math
5+
from typing import Any
6+
7+
from ac_infinity_ble import ACInfinityController
8+
9+
from homeassistant.components.fan import FanEntity, FanEntityFeature
10+
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.core import HomeAssistant, callback
13+
from homeassistant.helpers import device_registry as dr
14+
from homeassistant.helpers.entity import DeviceInfo
15+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
16+
from homeassistant.helpers.update_coordinator import (
17+
CoordinatorEntity,
18+
DataUpdateCoordinator,
19+
)
20+
from homeassistant.util.percentage import (
21+
int_states_in_range,
22+
ranged_value_to_percentage,
23+
percentage_to_ranged_value,
24+
)
25+
26+
from .const import DEVICE_MODEL, DOMAIN
27+
from .models import ACInfinityData
28+
29+
SPEED_RANGE = (1, 10)
30+
31+
32+
async def async_setup_entry(
33+
hass: HomeAssistant,
34+
entry: ConfigEntry,
35+
async_add_entities: AddEntitiesCallback,
36+
) -> None:
37+
"""Set up the light platform for LEDBLE."""
38+
data: ACInfinityData = hass.data[DOMAIN][entry.entry_id]
39+
async_add_entities([ACInfinityFan(data.coordinator, data.device, entry.title)])
40+
41+
42+
class ACInfinityFan(CoordinatorEntity, FanEntity):
43+
"""Representation of AC Infinity sensor."""
44+
45+
_attr_speed_count = int_states_in_range(SPEED_RANGE)
46+
_attr_supported_features = FanEntityFeature.SET_SPEED
47+
48+
def __init__(
49+
self,
50+
coordinator: DataUpdateCoordinator,
51+
device: ACInfinityController,
52+
name: str,
53+
) -> None:
54+
"""Initialize an AC Infinity sensor."""
55+
super().__init__(coordinator)
56+
self._device = device
57+
self._attr_name = f"{name} Fan"
58+
self._attr_unique_id = f"{self._device.address}_fan"
59+
self._attr_device_info = DeviceInfo(
60+
name=device.name,
61+
model=DEVICE_MODEL[device.state.type],
62+
manufacturer="AC Infinity",
63+
sw_version=device.state.version,
64+
connections={(dr.CONNECTION_BLUETOOTH, device.address)},
65+
)
66+
self._async_update_attrs()
67+
68+
async def async_set_percentage(self, percentage: int) -> None:
69+
"""Set the speed of the fan, as a percentage."""
70+
speed = 0
71+
if percentage > 0:
72+
speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
73+
74+
await self._device.set_speed(speed)
75+
self._async_update_attrs()
76+
77+
async def async_turn_on(
78+
self,
79+
percentage: int | None = None,
80+
preset_mode: str | None = None,
81+
**kwargs: Any,
82+
) -> None:
83+
"""Turn on the fan."""
84+
speed = None
85+
if percentage is not None:
86+
speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
87+
await self._device.turn_on(speed)
88+
self._async_update_attrs()
89+
90+
async def async_turn_off(self, **kwargs: Any) -> None:
91+
"""Turn off the fan."""
92+
await self._device.turn_off()
93+
self._async_update_attrs()
94+
95+
@callback
96+
def _async_update_attrs(self) -> None:
97+
"""Handle updating _attr values."""
98+
self._attr_is_on = self._device.is_on
99+
self._attr_percentage = ranged_value_to_percentage(
100+
SPEED_RANGE, self._device.state.fan
101+
)
102+
103+
@callback
104+
def _handle_coordinator_update(self, *args: Any) -> None:
105+
"""Handle data update."""
106+
self._async_update_attrs()
107+
self.async_write_ha_state()
108+
109+
async def async_added_to_hass(self) -> None:
110+
"""Register callbacks."""
111+
self.async_on_remove(
112+
self._device.register_callback(self._handle_coordinator_update)
113+
)
114+
return await super().async_added_to_hass()

0 commit comments

Comments
 (0)