diff --git a/README.md b/README.md index e39bf62..87b1f25 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Validate with hassfest](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/hassfest_validation.yml/badge.svg)](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/hassfest_validation.yml) [![HACS Action](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/HACS.yml/badge.svg)](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/HACS.yml) +[![Add repo to HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?repository=hassio-bluetti-bt&owner=Patrick762) Bluetti Integration for Home Assistant @@ -23,3 +24,7 @@ Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt). ### Available sensors: All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)) excluding battery pack fields. + +### Available controls: +If enabled in the Integration options (you need to reload the integration if you change this option): +AC and DC outputs diff --git a/custom_components/bluetti_bt/__init__.py b/custom_components/bluetti_bt/__init__.py index 34f8497..6c5a740 100644 --- a/custom_components/bluetti_bt/__init__.py +++ b/custom_components/bluetti_bt/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import logging from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -11,10 +12,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, MANUFACTURER +from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER, CONF_USE_CONTROLS, DATA_POLLING_RUNNING from .coordinator import PollingCoordinator PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -22,6 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: address = entry.data.get(CONF_ADDRESS) device_name = entry.data.get(CONF_NAME) + use_controls = entry.data.get(CONF_USE_CONTROLS) if address is None: return False @@ -29,16 +32,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not bluetooth.async_address_present(hass, address): raise ConfigEntryNotReady("Bluetti device not present") + # Create data structure + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(entry.entry_id, {}) + hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False) + # Create coordinator for polling coordinator = PollingCoordinator(hass, address, device_name) await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator) - # Create data structure - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(entry.entry_id, coordinator) + platforms: list = PLATFORMS + if use_controls is True: + _LOGGER.warning("You are using controls with this integration at your own risk!") + platforms.append(Platform.SWITCH) # Setup platforms - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, platforms) return True diff --git a/custom_components/bluetti_bt/binary_sensor.py b/custom_components/bluetti_bt/binary_sensor.py index f2e53d0..67cfe38 100644 --- a/custom_components/bluetti_bt/binary_sensor.py +++ b/custom_components/bluetti_bt/binary_sensor.py @@ -26,7 +26,7 @@ from bluetti_mqtt.mqtt_client import ( ) from . import device_info as dev_info, get_unique_id -from .const import DOMAIN, ADDITIONAL_DEVICE_FIELDS +from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS from .coordinator import PollingCoordinator, DummyDevice _LOGGER = logging.getLogger(__name__) @@ -63,7 +63,7 @@ async def async_setup_entry( sensors_to_add.append( BluettiBinarySensor( - hass.data[DOMAIN][entry.entry_id], + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], device_info, address, field_key, diff --git a/custom_components/bluetti_bt/config_flow.py b/custom_components/bluetti_bt/config_flow.py index 9823ec5..6e818e5 100644 --- a/custom_components/bluetti_bt/config_flow.py +++ b/custom_components/bluetti_bt/config_flow.py @@ -14,9 +14,10 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from . import get_type_by_bt_name -from .const import DOMAIN +from .const import DOMAIN, CONF_USE_CONTROLS _LOGGER = logging.getLogger(__name__) @@ -86,3 +87,56 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, ) + + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + changed = self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + **{ + CONF_USE_CONTROLS: user_input[CONF_USE_CONTROLS], + }, + }, + ) + if changed is False: + _LOGGER.error( + "Method OptionsFlowHandler.async_step_init: Config entry %s has not been changed", + self.config_entry.entry_id, + ) + return self.async_create_entry( + title="", + data={ + CONF_USE_CONTROLS: user_input[CONF_USE_CONTROLS], + }, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_USE_CONTROLS, + default=self.config_entry.data.get(CONF_USE_CONTROLS, False), + ): selector.BooleanSelector(), + } + ), + ) diff --git a/custom_components/bluetti_bt/const.py b/custom_components/bluetti_bt/const.py index 3b11800..2fdacb7 100644 --- a/custom_components/bluetti_bt/const.py +++ b/custom_components/bluetti_bt/const.py @@ -6,6 +6,15 @@ DOMAIN = "bluetti_bt" MANUFACTURER = "Bluetti" CONF_OPTIONS = "options" +CONF_USE_CONTROLS = "use_controls" + +DATA_COORDINATOR = "coordinator" +DATA_POLLING_RUNNING = "polling_running" + +CONTROL_FIELDS = [ + "ac_output_on", + "dc_output_on", +] DIAGNOSTIC_FIELDS = [ "max_ac_input_power", diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index f2338b3..0a62d41 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -1,12 +1,11 @@ """Coordinator for Bluetti integration.""" from __future__ import annotations -from typing import List +from typing import List, cast import asyncio from datetime import timedelta import logging -from typing import cast import async_timeout from bleak import BleakClient, BleakError @@ -27,6 +26,8 @@ from bluetti_mqtt.bluetooth import ( from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice from bluetti_mqtt.core.commands import ReadHoldingRegisters +from .const import DATA_POLLING_RUNNING, DOMAIN + _LOGGER = logging.getLogger(__name__) @@ -151,6 +152,8 @@ class PollingCoordinator(DataUpdateCoordinator): This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ + self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = True + self.logger.debug("Polling data") device = bluetooth.async_ble_device_from_address(self.hass, self._address) @@ -204,7 +207,7 @@ class PollingCoordinator(DataUpdateCoordinator): parsed_data.update(parsed) except TimeoutError: - self.logger.error( + self.logger.warning( "Polling timed out (address: %s)", self._address ) except ParseError: @@ -217,17 +220,19 @@ class PollingCoordinator(DataUpdateCoordinator): ) except (BadConnectionError, BleakError) as err: self.logger.warning( - "Needed to disconnect due to error: %s", err + "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err ) except TimeoutError: - self.logger.error("Polling timed out for device %s", self._address) + self.logger.warning("Polling timed out for device %s", self._address) return None except BleakError as err: - self.logger.error("Bleak error: %s", err) + self.logger.warning("Bleak error: %s", err) return None finally: await client.disconnect() + self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False + # Pass data back to sensors return parsed_data diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index fa32848..66ca581 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -1,6 +1,7 @@ """Bluetti BT sensors.""" from __future__ import annotations +from enum import Enum import logging from decimal import Decimal @@ -29,7 +30,7 @@ from bluetti_mqtt.mqtt_client import ( ) from . import device_info as dev_info, get_unique_id -from .const import DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS +from .const import DATA_COORDINATOR, DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS from .coordinator import PollingCoordinator, DummyDevice _LOGGER = logging.getLogger(__name__) @@ -65,7 +66,7 @@ async def async_setup_entry( if field_config.type == MqttFieldType.NUMERIC: sensors_to_add.append( BluettiSensor( - hass.data[DOMAIN][entry.entry_id], + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], device_info, address, field_key, @@ -79,7 +80,7 @@ async def async_setup_entry( elif field_config.type == MqttFieldType.ENUM: sensors_to_add.append( BluettiSensor( - hass.data[DOMAIN][entry.entry_id], + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], device_info, address, field_key, @@ -146,6 +147,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): and not isinstance(response_data, float) and not isinstance(response_data, complex) and not isinstance(response_data, Decimal) + and not isinstance(response_data, Enum) ): _LOGGER.warning( "Invalid response data type from coordinator (sensor.%s): %s has type %s", @@ -161,11 +163,11 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): # Different for enum and numeric if ( self._options is not None - and isinstance(response_data, int) - and response_data < len(self._options) + and isinstance(response_data, Enum) + and response_data.value < len(self._options) ): # Enum - self._attr_native_value = self._options[response_data] + self._attr_native_value = self._options[response_data.value] else: # Numeric self._attr_native_value = response_data diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py new file mode 100644 index 0000000..0d6a83e --- /dev/null +++ b/custom_components/bluetti_bt/switch.py @@ -0,0 +1,188 @@ +"""Bluetti BT switches.""" + +from __future__ import annotations + +import asyncio +import logging +import async_timeout + +from bleak import BleakClient, BleakError + +from homeassistant.components import bluetooth +from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.const import ( + CONF_ADDRESS, + CONF_NAME, + EntityCategory, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) + +from bluetti_mqtt.bluetooth import build_device +from bluetti_mqtt.bluetooth.client import BluetoothClient +from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice +from bluetti_mqtt.mqtt_client import ( + NORMAL_DEVICE_FIELDS, + DC_INPUT_FIELDS, + MqttFieldType, +) + +from . import device_info as dev_info, get_unique_id +from .const import CONTROL_FIELDS, DATA_COORDINATOR, DATA_POLLING_RUNNING, DOMAIN, ADDITIONAL_DEVICE_FIELDS +from .coordinator import PollingCoordinator, DummyDevice + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Setup switch entities.""" + + device_name = entry.data.get(CONF_NAME) + address = entry.data.get(CONF_ADDRESS) + if address is None: + _LOGGER.error("Device has no address") + + # Generate device info + _LOGGER.info("Creating sensors for device with address %s", address) + device_info = dev_info(entry) + + # Add sensors according to device_info + bluetti_device = build_device(address, device_name) + bluetti_device = DummyDevice(bluetti_device) + + sensors_to_add = [] + all_fields = NORMAL_DEVICE_FIELDS + all_fields.update(DC_INPUT_FIELDS) + all_fields.update(ADDITIONAL_DEVICE_FIELDS) + for field_key, field_config in all_fields.items(): + if bluetti_device.has_field(field_key): + if field_config.type == MqttFieldType.BOOL: + if field_config.setter is True and field_key in CONTROL_FIELDS: + sensors_to_add.append( + BluettiSwitch( + bluetti_device, + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], + device_info, + address, + field_key, + field_config.home_assistant_extra.get(CONF_NAME, ""), + entry.entry_id + ) + ) + + async_add_entities(sensors_to_add) + + +class BluettiSwitch(CoordinatorEntity, SwitchEntity): + """Bluetti universal switch.""" + + def __init__( + self, + bluetti_device: BluettiDevice, + coordinator: PollingCoordinator, + device_info: DeviceInfo, + address, + response_key: str, + name: str, + entry_id: str, + category: EntityCategory | None = None, + ): + """Init entity.""" + super().__init__(coordinator) + + self._bluetti_device = bluetti_device + e_name = f"{device_info.get('name')} {name}" + self._address = address + self._response_key = response_key + self._entry_id = entry_id + + self._attr_device_info = device_info + self._attr_name = e_name + self._attr_available = False + self._attr_unique_id = get_unique_id(e_name) + self._attr_entity_category = category + self._attr_device_class = SwitchDeviceClass.OUTLET + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug("Updating state of %s", self._attr_unique_id) + if not isinstance(self.coordinator.data, dict): + _LOGGER.error( + "Invalid data from coordinator (sensor.%s)", self._attr_unique_id + ) + self._attr_available = False + return + + response_data = self.coordinator.data.get(self._response_key) + if response_data is None: + self._attr_available = False + return + + if not isinstance(response_data, bool): + _LOGGER.warning( + "Invalid response data type from coordinator (sensor.%s): %s", + self._attr_unique_id, + response_data, + ) + self._attr_available = False + return + + self._attr_available = True + self._attr_is_on = self.coordinator.data[self._response_key] is True + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + _LOGGER.debug("Turn on %s on %s", self._response_key, self._address) + await self.write_to_device(True) + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + _LOGGER.debug("Turn off %s on %s", self._response_key, self._address) + await self.write_to_device(False) + + async def write_to_device(self, state: bool): + """Write to device.""" + device = bluetooth.async_ble_device_from_address(self.hass, self._address) + if device is None: + _LOGGER.error("Device %s not available", self._address) + return + + command = self._bluetti_device.build_setter_command(self._response_key, state) + + # Wait until polling is done + while self.hass.data[DOMAIN][self._entry_id][DATA_POLLING_RUNNING] is True: + _LOGGER.debug("Waiting for polling to finish...") + asyncio.sleep(1) + + client = BleakClient(device) + try: + async with async_timeout.timeout(15): + await client.connect() + + # Send command + await client.write_gatt_char( + BluetoothClient.WRITE_UUID, bytes(command) + ) + + # Wait until device has changed value, otherwise reading register might reset it + await asyncio.sleep(5) + + except TimeoutError: + _LOGGER.error("Timed out for device %s", self._address) + return None + except BleakError as err: + _LOGGER.error("Bleak error: %s", err) + return None + finally: + await client.disconnect() + + await self.coordinator.async_request_refresh() diff --git a/custom_components/bluetti_bt/translations/de.json b/custom_components/bluetti_bt/translations/de.json new file mode 100644 index 0000000..8b1d377 --- /dev/null +++ b/custom_components/bluetti_bt/translations/de.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "use_controls": "Steuerung aktivieren (auf eigenes Risiko)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/bluetti_bt/translations/en.json b/custom_components/bluetti_bt/translations/en.json new file mode 100644 index 0000000..d3fceda --- /dev/null +++ b/custom_components/bluetti_bt/translations/en.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "use_controls": "Use controls (use at own risk)" + } + } + } + } +} \ No newline at end of file