Skip to content

Commit

Permalink
Refactor of integration
Browse files Browse the repository at this point in the history
  • Loading branch information
npike committed Aug 13, 2024
1 parent 1a4929d commit 63920a8
Show file tree
Hide file tree
Showing 15 changed files with 769 additions and 200 deletions.
67 changes: 44 additions & 23 deletions custom_components/ecoflow-ble/__init__.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,70 @@
"""The Ecoflow Bluetooth BLE integration."""
"""The ecoflow_ble integration."""

from __future__ import annotations

import logging

from .ecoflow_ble import EcoflowBluetoothDeviceData
from .ecoflow_ble import EcoflowController, DeviceInfo

from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import (
CONF_ADDRESS,
CONF_SERVICE_DATA,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

from .const import DOMAIN
from .coordinator import EcoflowDataUpdateCoordinator
from .models import EcoflowData

PLATFORMS: list[Platform] = [Platform.SENSOR]

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ecoflow BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = EcoflowBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
"""Set up ecoflow_ble 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 Ecoflow device with address {address}"
)

device_info: DeviceInfo | dict = entry.data[CONF_SERVICE_DATA]
if type(device_info) is dict:
device_info = DeviceInfo(**entry.data[CONF_SERVICE_DATA])
controller = EcoflowController(ble_device, device_info)
coordinator = EcoflowDataUpdateCoordinator(hass, _LOGGER, ble_device, controller)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EcoflowData(
entry.title, controller, coordinator
)

entry.async_on_unload(coordinator.async_start())
if not await coordinator.async_wait_ready():
raise ConfigEntryNotReady(f"{address} is not advertising state")

entry.async_on_unload(entry.add_update_listener(_async_update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe

return True


async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
data: EcoflowData = 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):
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
return unload_ok
133 changes: 80 additions & 53 deletions custom_components/ecoflow-ble/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,97 +1,124 @@
"""Config flow for ecoflow ble integration."""
"""Config flow for ecoflow."""

from __future__ import annotations

import logging
from typing import Any

from .ecoflow_ble import EcoflowBluetoothDeviceData as DeviceData
from .ecoflow_ble import EcoflowController, DeviceInfo
from .ecoflow_ble.protocol import parse_manufacturer_data
from .ecoflow_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.config_entries import ConfigFlow
from homeassistant.const import CONF_ADDRESS
from homeassistant.const import CONF_ADDRESS, CONF_SERVICE_DATA
from homeassistant.data_entry_flow import FlowResult

from .const import DOMAIN
from .const import BLEAK_EXCEPTIONS, DOMAIN


_LOGGER = logging.getLogger(__name__)


class EcoflowConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ecoflow."""
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_device: DeviceData | None = None
self._discovered_devices: dict[str, str] = {}

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()
device = DeviceData()

if not device.supported(discovery_info):

return self.async_abort(reason="not_supported")
self._discovery_info = discovery_info
self._discovered_device = device

return await self.async_step_bluetooth_confirm()

async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovered_device is not None
device = self._discovered_device
assert self._discovery_info is not None
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})

self._set_confirm_only()
placeholders = {"name": title}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="bluetooth_confirm", description_placeholders=placeholders
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]
await self.async_set_unique_id(address, raise_on_progress=False)
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()
return self.async_create_entry(
title=self._discovered_devices[address], data={}
controller = EcoflowController(
discovery_info.device, advertisement_data=discovery_info.advertisement
)

current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or discovery_info.name
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():
try:
device = parse_manufacturer_data(
service_info.advertisement.manufacturer_data[MANUFACTURER_ID]
)
devices[service_info.address] = (
f"{device.name} ({service_info.address})"
)
except KeyError:
# Discovered device is not an AC Infinity device
pass

data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(devices),
}
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)
data_schema=data_schema,
errors=errors,
)
7 changes: 5 additions & 2 deletions custom_components/ecoflow-ble/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
""" Constants """
"""Constants"""

from bleak.exc import BleakError
# Component constants

DOMAIN = "ecoflow_ble"
PLATFORM = "sensor"
PLATFORM = "sensor"

BLEAK_EXCEPTIONS = (AttributeError, BleakError, TimeoutError)
105 changes: 105 additions & 0 deletions custom_components/ecoflow-ble/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Ecoflow Coordinator."""

from __future__ import annotations

import asyncio
import contextlib
import logging

from .ecoflow_ble import EcoflowController
import async_timeout
from bleak.backends.device import BLEDevice

from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.active_update_coordinator import (
ActiveBluetoothDataUpdateCoordinator,
)
from homeassistant.core import CoreState, HomeAssistant, callback


DEVICE_STARTUP_TIMEOUT = 30


class EcoflowDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
"""Class to manage fetching ecoflow data."""

def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
ble_device: BLEDevice,
controller: EcoflowController,
) -> None:
"""Initialize global ecoflow data updater."""
super().__init__(
hass=hass,
logger=logger,
address=ble_device.address,
needs_poll_method=self._needs_poll,
poll_method=self._async_update,
mode=bluetooth.BluetoothScanningMode.ACTIVE,
connectable=True,
)
self.ble_device = ble_device
self.controller = controller
self._ready_event = asyncio.Event()
self._was_unavailable = True

@callback
def _needs_poll(
self,
service_info: bluetooth.BluetoothServiceInfoBleak,
seconds_since_last_poll: float | None,
) -> bool:
# Only poll if hass is running, we need to poll,
# and we actually have a way to connect to the device
return (
self.hass.state == CoreState.running
and (seconds_since_last_poll is None or seconds_since_last_poll > 30)
and bool(
bluetooth.async_ble_device_from_address(
self.hass, service_info.device.address, connectable=True
)
)
)

async def _async_update(
self, service_info: bluetooth.BluetoothServiceInfoBleak
) -> None:
"""Poll the device."""
await self.controller.update()

@callback
def _async_handle_unavailable(
self, service_info: bluetooth.BluetoothServiceInfoBleak
) -> None:
"""Handle the device going unavailable."""
super()._async_handle_unavailable(service_info)
self._was_unavailable = True

@callback
def _async_handle_bluetooth_event(
self,
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
"""Handle a Bluetooth event."""
self.ble_device = service_info.device
self.controller.set_ble_device_and_advertisement_data(
service_info.device, service_info.advertisement
)
if self.controller.name:
self._ready_event.set()
self.logger.debug(
"%s: Ecoflow data: %s", self.ble_device.address, self.controller._state
)
self._was_unavailable = False
super()._async_handle_bluetooth_event(service_info, change)

async def async_wait_ready(self) -> bool:
"""Wait for the device to be ready."""
with contextlib.suppress(asyncio.TimeoutError):
async with async_timeout.timeout(DEVICE_STARTUP_TIMEOUT):
await self._ready_event.wait()
return True
return False
Loading

0 comments on commit 63920a8

Please sign in to comment.