-
Notifications
You must be signed in to change notification settings - Fork 0
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
15 changed files
with
769 additions
and
200 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 |
---|---|---|
@@ -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 |
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 |
---|---|---|
@@ -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, | ||
) |
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 |
---|---|---|
@@ -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) |
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,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 |
Oops, something went wrong.