diff --git a/README.md b/README.md index 38e5292..b0a50be 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ Bluetti Integration for Home Assistant -Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt). - ## Disclaimer This integration is provided without any warranty or support by Bluetti (unfortunately). I do not take responsibility for any problems it may cause in all cases. Use it at your own risk. @@ -17,7 +15,6 @@ After the installation, you can use this button to install the integration: [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Patrick762&repository=hassio-bluetti-bt&category=integration) ### Supported devices: -(based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices)) - AC60 - AC200M @@ -29,9 +26,6 @@ After the installation, you can use this button to install the integration: - EP600 (tested) - EP760 (beta, untested) -### 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)). - ### 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 9ef157b..ea7e4aa 100644 --- a/custom_components/bluetti_bt/__init__.py +++ b/custom_components/bluetti_bt/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import re import logging +from typing import List from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,7 @@ from .const import ( ) from .coordinator import PollingCoordinator -PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: List[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -83,25 +84,3 @@ def get_unique_id(name: str, sensor_type: str | None = None): if sensor_type is not None: return f"{sensor_type}.{res}" return res - - -def get_type_by_bt_name(bt_name: str): - """Get the device type.""" - dev_type = "Unknown" - if bt_name.startswith("AC200M"): - dev_type = "AC200M" - elif bt_name.startswith("AC300"): - dev_type = "AC300" - elif bt_name.startswith("AC500"): - dev_type = "AC500" - elif bt_name.startswith("AC60"): - dev_type = "AC60" - elif bt_name.startswith("EB3A"): - dev_type = "EB3A" - elif bt_name.startswith("EP500"): - dev_type = "EP500" - elif bt_name.startswith("EP600"): - dev_type = "EP600" - elif bt_name.startswith("EP760"): - dev_type = "EP760" - return dev_type diff --git a/custom_components/bluetti_bt/binary_sensor.py b/custom_components/bluetti_bt/binary_sensor.py index b8e5598..7737c63 100644 --- a/custom_components/bluetti_bt/binary_sensor.py +++ b/custom_components/bluetti_bt/binary_sensor.py @@ -18,16 +18,13 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) -from bluetti_mqtt.mqtt_client import ( - NORMAL_DEVICE_FIELDS, - DC_INPUT_FIELDS, - MqttFieldType, -) +from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, FieldType +from .bluetti_bt_lib.utils.device_builder import build_device from . import device_info as dev_info, get_unique_id -from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS +from .const import DATA_COORDINATOR, DOMAIN from .coordinator import PollingCoordinator -from .utils import unique_id_loggable, build_device +from .utils import unique_id_loggable _LOGGER = logging.getLogger(__name__) @@ -50,12 +47,10 @@ async def async_setup_entry( bluetti_device = build_device(address, device_name) sensors_to_add = [] - all_fields = NORMAL_DEVICE_FIELDS - all_fields.update(DC_INPUT_FIELDS) - all_fields.update(ADDITIONAL_DEVICE_FIELDS) + all_fields = FIELD_ATTRIBUTES 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.type == FieldType.BOOL: category = None if field_config.setter is True: category = EntityCategory.DIAGNOSTIC @@ -66,7 +61,7 @@ async def async_setup_entry( device_info, address, field_key, - field_config.home_assistant_extra.get(CONF_NAME, ""), + field_config.name, category=category, ) ) @@ -104,7 +99,7 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: + if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected: return _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/__init__.py b/custom_components/bluetti_bt/bluetti_bt_lib/__init__.py new file mode 100644 index 0000000..36a27e2 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/__init__.py @@ -0,0 +1,3 @@ +from .const import * +from .exceptions import * +from .utils.commands import ReadHoldingRegisters diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/BluettiDevice.py b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/BluettiDevice.py new file mode 100644 index 0000000..dcc5cd7 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/BluettiDevice.py @@ -0,0 +1,65 @@ +"""Bluetti device.""" + +# Reduced copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/bluetti_device.py + +from typing import Any, List + +from ..utils.commands import ReadHoldingRegisters, WriteSingleRegister +from ..utils.struct import BoolField, DeviceStruct, EnumField + + +class BluettiDevice: + struct: DeviceStruct + + def __init__(self, address: str, type: str, sn: str): + self.address = address + self.type = type + self.sn = sn + + def parse(self, address: int, data: bytes) -> dict: + return self.struct.parse(address, data) + + @property + def pack_num_max(self): + """ + A given device has a maximum number of battery packs, including the + internal battery if it has one. We can provide this information statically + so it's not necessary to poll the device. + """ + return 1 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + """A given device has an optimal set of commands for polling""" + raise NotImplementedError + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + """A given device may have a set of commands for polling pack data""" + return [] + + @property + def writable_ranges(self) -> List[range]: + """The address ranges that are writable""" + return [] + + def has_field(self, field: str): + return any(f.name == field for f in self.struct.fields) + + def has_field_setter(self, field: str): + matches = [f for f in self.struct.fields if f.name == field] + return any(any(f.address in r for r in self.writable_ranges) for f in matches) + + def build_setter_command(self, field: str, value: Any): + matches = [f for f in self.struct.fields if f.name == field] + device_field = next( + f for f in matches if any(f.address in r for r in self.writable_ranges) + ) + + # Convert value to an integer + if isinstance(device_field, EnumField): + value = device_field.enum[value].value + elif isinstance(device_field, BoolField): + value = 1 if value else 0 + + return WriteSingleRegister(device_field.address, value) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV1Device.py b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV1Device.py new file mode 100644 index 0000000..b7d717d --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV1Device.py @@ -0,0 +1,75 @@ +"""Base device definition for V1 Protocol devices.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..utils.struct import DeviceStruct +from .BluettiDevice import BluettiDevice + + +class ProtocolV1Device(BluettiDevice): + def __init__(self, address: str, type: str, sn: str): + self.struct = DeviceStruct() + + # Device info + self.struct.add_string_field("device_type", 10, 6) + self.struct.add_sn_field("serial_number", 17) + + # Power IO + self.struct.add_uint_field("dc_input_power", 36) + self.struct.add_uint_field("ac_input_power", 37) + self.struct.add_uint_field("ac_output_power", 38) + self.struct.add_uint_field("dc_output_power", 39) + + # Power IO statistics + self.struct.add_decimal_field( + "power_generation", 41, 1 + ) # Total power generated since last reset (kwh) + + # Battery + self.struct.add_uint_field("total_battery_percent", 43) + + # Battery packs + self.struct.add_uint_field("pack_num_max", 91) # internal + self.struct.add_decimal_field("total_battery_voltage", 92, 1) + self.struct.add_uint_field("pack_num", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + + # Output state + self.struct.add_bool_field("ac_output_on", 48) + self.struct.add_bool_field("dc_output_on", 49) + + # Pack selector + self.struct.add_uint_field("pack_num", 3006) # internal + + # Output controls + self.struct.add_bool_field("ac_output_on_switch", 3007) + self.struct.add_bool_field("dc_output_on_switch", 3008) + + super().__init__(address, type, sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 10), + ReadHoldingRegisters(36, 4), + ReadHoldingRegisters(41, 1), + ReadHoldingRegisters(43, 1), + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + ReadHoldingRegisters(48, 2), + ] + + @property + def writable_ranges(self) -> List[range]: + return [range(3006, 3008)] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV2Device.py b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV2Device.py new file mode 100644 index 0000000..b383c6c --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV2Device.py @@ -0,0 +1,69 @@ +"""Base device definition for V2 Protocol devices.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..utils.struct import DeviceStruct +from .BluettiDevice import BluettiDevice + + +class ProtocolV2Device(BluettiDevice): + def __init__(self, address: str, type: str, sn: str): + self.struct = DeviceStruct() + + # Device info + self.struct.add_swap_string_field("device_type", 110, 6) + self.struct.add_sn_field("serial_number", 116) + + # Power IO + # self.struct.add_uint_field('dc_input_power', ?) + # self.struct.add_uint_field('ac_input_power', ?) + # self.struct.add_uint_field('ac_output_power', ?) + # self.struct.add_uint_field('dc_output_power', ?) + + # Power IO statistics + self.struct.add_decimal_field( + "power_generation", 154, 1 + ) # Total power generated since last reset (kwh) + + # Battery + self.struct.add_uint_field("total_battery_percent", 102) + + # Battery packs + # self.struct.add_uint_field('pack_num_max', ?) # internal + # self.struct.add_decimal_field('total_battery_voltage', ?, 1) + # self.struct.add_uint_field('pack_num', ?) # internal + # self.struct.add_decimal_field('pack_voltage', ?, 2) # Full pack voltage + # self.struct.add_uint_field('pack_battery_percent', ?) + + # Output state + # self.struct.add_bool_field('ac_output_on', ?) + # self.struct.add_bool_field('dc_output_on', ?) + + # Pack selector + # self.struct.add_uint_field('pack_num', ?) # internal + + # Output controls + # self.struct.add_bool_field('ac_output_on_switch', ?) + # self.struct.add_bool_field('dc_output_on_switch', ?) + + # Add battery fields here? + + super().__init__(address, type, sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(110, 6), + ReadHoldingRegisters(116, 4), + ReadHoldingRegisters(154, 1), + ReadHoldingRegisters(102, 1), + ] + + @property + def writable_ranges(self) -> List[range]: + return [] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_reader.py b/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_reader.py new file mode 100644 index 0000000..ffd309d --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_reader.py @@ -0,0 +1,219 @@ +"""Device reader.""" + +import asyncio +import logging +from typing import Any, Callable, List, cast +import async_timeout +from bleak import BleakClient, BleakError + +from ..base_devices.BluettiDevice import BluettiDevice +from ..const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID +from ..exceptions import BadConnectionError, ModbusError, ParseError +from ..utils.commands import ReadHoldingRegisters + +_LOGGER = logging.getLogger(__name__) + + +class DeviceReader: + def __init__( + self, + bleak_client: BleakClient, + bluetti_device: BluettiDevice, + future_builder_method: Callable[[], asyncio.Future[Any]], + persistent_conn: bool = False, + polling_timeout: int = 45, + max_retries: int = 5, + ) -> None: + self.client = bleak_client + self.bluetti_device = bluetti_device + self.create_future = future_builder_method + self.persistent_conn = persistent_conn + self.polling_timeout = polling_timeout + self.max_retries = max_retries + + self.has_notifier = False + self.notify_future: asyncio.Future[Any] | None = None + self.current_command = None + self.notify_response = bytearray() + + # polling mutex to guard against switches + self.polling_lock = asyncio.Lock() + + async def read_data( + self, filter_registers: List[ReadHoldingRegisters] | None = None + ) -> dict | None: + _LOGGER.info("Reading data") + + if self.bluetti_device is None: + _LOGGER.error("Device is None") + return None + + polling_commands = self.bluetti_device.polling_commands + pack_commands = self.bluetti_device.pack_polling_commands + if filter_registers is not None: + polling_commands = filter_registers + pack_commands = [] + + parsed_data: dict = {} + + async with self.polling_lock: + try: + async with async_timeout.timeout(self.polling_timeout): + # Reconnect if not connected + for attempt in range(1, self.max_retries + 1): + try: + if not self.client.is_connected: + await self.client.connect() + break + except Exception as e: + if attempt == self.max_retries or attempt == 1: + raise # pass exception on max_retries attempt + else: + _LOGGER.warning( + f"Connect unsucessful (attempt {attempt}): {e}. Retrying..." + ) + await asyncio.sleep(2) + + # Attach notifier if needed + if not self.has_notifier: + await self.client.start_notify( + NOTIFY_UUID, self._notification_handler + ) + self.has_notifier = True + + # Execute polling commands + for command in polling_commands: + try: + body = command.parse_response( + await self._async_send_command(command) + ) + _LOGGER.debug("Raw data: %s", body) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + _LOGGER.debug("Parsed data: %s", parsed) + parsed_data.update(parsed) + except ParseError: + _LOGGER.warning("Got a parse exception") + + # Execute pack polling commands + if len(pack_commands) > 0: + _LOGGER.debug("Polling battery packs") + for pack in range(1, self.bluetti_device.pack_num_max + 1): + # Set current pack number + await self._async_send_command( + self.bluetti_device.build_setter_command( + "pack_num", pack + ) + ) + + # We need to wait after switching packs for the data to be available + await asyncio.sleep(5) + + for command in pack_commands: + # Request & parse result for each pack + try: + body = command.parse_response( + await self._async_send_command(command) + ) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + _LOGGER.debug("Parsed data: %s", parsed) + + pack_number = parsed.get("pack_num") + if ( + not isinstance(pack_number, int) + or pack_number != pack + ): + _LOGGER.debug( + "Parsed pack_num(%s) does not match expected '%s'", + pack_number, + pack, + ) + continue + + for key, value in parsed.items(): + parsed_data.update({key + str(pack): value}) + + except ParseError: + _LOGGER.warning("Got a parse exception...") + + except TimeoutError: + _LOGGER.error("Polling timed out") + return None + except BleakError as err: + _LOGGER.error("Bleak error: %s", err) + return None + finally: + # Disconnect if connection not persistant + if not self.persistent_conn: + if self.has_notifier: + await self.client.stop_notify(NOTIFY_UUID) + self.has_notifier = False + await self.client.disconnect() + + return parsed_data + + async def _async_send_command(self, command: ReadHoldingRegisters) -> bytes: + """Send command and return response""" + try: + # Prepare to make request + self.current_command = command + self.notify_future = self.create_future() + self.notify_response = bytearray() + + # Make request + _LOGGER.debug("Requesting %s", command) + await self.client.write_gatt_char(WRITE_UUID, bytes(command)) + + # Wait for response + res = await asyncio.wait_for(self.notify_future, timeout=RESPONSE_TIMEOUT) + + # Process data + _LOGGER.debug("Got %s bytes", len(res)) + return cast(bytes, res) + + except TimeoutError: + _LOGGER.warning("Polling single command timed out") + except ModbusError as err: + _LOGGER.warning( + "Got an invalid request error for %s: %s", + command, + err, + ) + except (BadConnectionError, BleakError) as err: + _LOGGER.warning( + "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", + err, + ) + + # caught an exception, return empty bytes object + return bytes() + + def _notification_handler(self, _sender: int, data: bytearray): + """Handle bt data.""" + + # Ignore notifications we don't expect + if self.notify_future is None or self.notify_future.done(): + _LOGGER.warning("Unexpected notification") + return + + # If something went wrong, we might get weird data. + if data == b"AT+NAME?\r" or data == b"AT+ADV?\r": + err = BadConnectionError("Got AT+ notification") + self.notify_future.set_exception(err) + return + + # Save data + self.notify_response.extend(data) + + if len(self.notify_response) == self.current_command.response_size(): + if self.current_command.is_valid_response(self.notify_response): + self.notify_future.set_result(self.notify_response) + else: + self.notify_future.set_exception(ParseError("Failed checksum")) + elif self.current_command.is_exception_response(self.notify_response): + # We got a MODBUS command exception + msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}" + self.notify_future.set_exception(ModbusError(msg)) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_recognizer.py b/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_recognizer.py new file mode 100644 index 0000000..dc5b426 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_recognizer.py @@ -0,0 +1,59 @@ +"""Device recognizer.""" + +import asyncio +import logging +from typing import Any, Callable +from bleak import BleakClient + +from custom_components.bluetti_bt.bluetti_bt_lib.utils.commands import ( + ReadHoldingRegisters, +) + +from ..base_devices.ProtocolV2Device import ProtocolV2Device +from ..bluetooth.device_reader import DeviceReader + +_LOGGER = logging.getLogger(__name__) + + +async def recognize_device( + bleak_client: BleakClient, + future_builder_method: Callable[[], asyncio.Future[Any]], +) -> str: + + # Since we don't know the type we use the base device + bluetti_device = ProtocolV2Device("Unknown", "Unknown", "Unknown") + + # Create device builder + device_reader = DeviceReader(bleak_client, bluetti_device, future_builder_method) + + # Retry a few times to get data + for _ in range(1, 50): + # We only need 6 registers to get the device type + data = await device_reader.read_data( + [ + ReadHoldingRegisters(110, 6), + ] + ) + + if data is not None: + break + + await asyncio.sleep(0.1) + + if data is None: + # Should not happen + return "Unknown" + + field_data = data.get("device_type") + + if field_data is None: + # We have a problem + _LOGGER.error("No data in device type field_datas. Maybe it's a V1 device?") + return "Unknown" + + if not isinstance(field_data, str): + # We have a problem + _LOGGER.error("Invalid data in device type field_datas") + return "Unknown" + + return field_data diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/const.py b/custom_components/bluetti_bt/bluetti_bt_lib/const.py new file mode 100644 index 0000000..6524cad --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/const.py @@ -0,0 +1,6 @@ +"""Const definitions.""" + +RESPONSE_TIMEOUT = 5 +WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb" +NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" +DEVICE_NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb" diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200m.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200m.py new file mode 100644 index 0000000..f5fa41a --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200m.py @@ -0,0 +1,48 @@ +"""AC200M fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import AutoSleepMode, OutputMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class AC200M(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC200M", sn) + + # Details + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_uint_field("internal_ac_voltage", 71) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_uint_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 1) + self.struct.add_uint_field("internal_dc_input_voltage", 86) + self.struct.add_decimal_field("internal_dc_input_power", 87, 1) + self.struct.add_decimal_field("internal_dc_input_current", 88, 2) + + # Battery packs + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls + self.struct.add_bool_field("power_off", 3060) + self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode) + + @property + def pack_num_max(self): + return 3 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(70, 4), + ReadHoldingRegisters(86, 3), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [range(3060, 3061)] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return super().pack_polling_commands + [ReadHoldingRegisters(105, 16)] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac300.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac300.py new file mode 100644 index 0000000..5151c32 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac300.py @@ -0,0 +1,69 @@ +"""AC300 fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class AC300(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC300", sn) + + # Details (matches EP500 + AC500) + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_uint_field("internal_ac_voltage", 71) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_uint_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 1) + self.struct.add_decimal_field("internal_current_two", 75, 1) + self.struct.add_uint_field("internal_power_two", 76) + self.struct.add_decimal_field("ac_input_voltage", 77, 1) + self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100)) + self.struct.add_uint_field("internal_power_three", 79) + self.struct.add_decimal_field("ac_input_frequency", 80, 2) + self.struct.add_uint_field("internal_dc_input_voltage", 86) + self.struct.add_decimal_field("internal_dc_input_power", 87, 1) + self.struct.add_decimal_field("internal_dc_input_current", 88, 2) + + # Battery packs + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + self.struct.add_version_field("pack_bms_version", 201) + + # Controls (matches EP500 + AC500) + self.struct.add_enum_field("ups_mode", 3001, UpsMode) + self.struct.add_bool_field("split_phase_on", 3004) + self.struct.add_enum_field( + "split_phase_machine_mode", 3005, SplitPhaseMachineType + ) + self.struct.add_bool_field("grid_charge_on", 3011) + self.struct.add_bool_field("time_control_on", 3013) + self.struct.add_uint_field("battery_range_start", 3015) + self.struct.add_uint_field("battery_range_end", 3016) + self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode) + + @property + def pack_num_max(self): + return 4 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(70, 10), + ReadHoldingRegisters(86, 3), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3016), + 3061, + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return super().pack_polling_commands + [ + ReadHoldingRegisters(105, 16), + ReadHoldingRegisters(201, 2), + ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac500.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac500.py new file mode 100644 index 0000000..12edcb2 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac500.py @@ -0,0 +1,67 @@ +"""AC500 fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class AC500(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC500", sn) + + # Details (matches EP500 + AC300) + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_uint_field("internal_ac_voltage", 71) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_uint_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 1) + self.struct.add_decimal_field("internal_current_two", 75, 1) + self.struct.add_uint_field("internal_power_two", 76) + self.struct.add_decimal_field("ac_input_voltage", 77, 1) + self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100)) + self.struct.add_uint_field("internal_power_three", 79) + self.struct.add_decimal_field("ac_input_frequency", 80, 2) + self.struct.add_uint_field("internal_dc_input_voltage", 86) + self.struct.add_decimal_field("internal_dc_input_power", 87, 1) + self.struct.add_decimal_field("internal_dc_input_current", 88, 2) + + # Battery packs (matches EP500) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls (matches EP500 + AC300) + self.struct.add_enum_field("ups_mode", 3001, UpsMode) + self.struct.add_bool_field("split_phase_on", 3004) + self.struct.add_enum_field( + "split_phase_machine_mode", 3005, SplitPhaseMachineType + ) + self.struct.add_bool_field("grid_charge_on", 3011) + self.struct.add_bool_field("time_control_on", 3013) + self.struct.add_uint_field("battery_range_start", 3015) + self.struct.add_uint_field("battery_range_end", 3016) + self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode) + + @property + def pack_num_max(self): + return 4 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(70, 10), + ReadHoldingRegisters(86, 3), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3016), + 3061, + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return super().pack_polling_commands + [ + ReadHoldingRegisters(105, 16), + ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac60.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac60.py new file mode 100644 index 0000000..de8a612 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac60.py @@ -0,0 +1,8 @@ +"""AC60 fields.""" + +from ..base_devices.ProtocolV2Device import ProtocolV2Device + + +class AC60(ProtocolV2Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC60", sn) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/eb3a.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/eb3a.py new file mode 100644 index 0000000..6f37a66 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/eb3a.py @@ -0,0 +1,39 @@ +"""EB3A fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import ChargingMode, EcoShutdown, LedMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class EB3A(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "EB3A", sn) + + # Details + self.struct.add_decimal_field("ac_input_voltage", 77, 1) + self.struct.add_uint_field("internal_dc_input_voltage", 86) + + # Controls + self.struct.add_enum_field("led_mode", 3034, LedMode) + self.struct.add_bool_field("power_off", 3060) + self.struct.add_bool_field("eco_on", 3063) + self.struct.add_enum_field("eco_shutdown", 3064, EcoShutdown) + self.struct.add_enum_field("charging_mode", 3065, ChargingMode) + self.struct.add_bool_field("power_lifting_on", 3066) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(77, 1), + ReadHoldingRegisters(86, 1), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + 3034, + 3060, + range(3063, 3066), + ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500.py new file mode 100644 index 0000000..1c5ff4a --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500.py @@ -0,0 +1,63 @@ +"""EP500 fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class EP500(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "EP500", sn) + + # Details (matches AC500 + AC300) + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_uint_field("internal_ac_voltage", 71) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_uint_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 1) + self.struct.add_decimal_field("internal_current_two", 75, 1) + self.struct.add_uint_field("internal_power_two", 76) + self.struct.add_decimal_field("ac_input_voltage", 77, 1) + self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100)) + self.struct.add_uint_field("internal_power_three", 79) + self.struct.add_decimal_field("ac_input_frequency", 80, 2) + self.struct.add_uint_field("internal_dc_input_voltage", 86) + self.struct.add_decimal_field("internal_dc_input_power", 87, 1) + self.struct.add_decimal_field("internal_dc_input_current", 88, 2) + + # Battery packs (matches AC500) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls (matches AC500 + AC300) + self.struct.add_enum_field("ups_mode", 3001, UpsMode) + self.struct.add_bool_field("split_phase_on", 3004) + self.struct.add_enum_field( + "split_phase_machine_mode", 3005, SplitPhaseMachineType + ) + self.struct.add_bool_field("grid_charge_on", 3011) + self.struct.add_bool_field("time_control_on", 3013) + self.struct.add_uint_field("battery_range_start", 3015) + self.struct.add_uint_field("battery_range_end", 3016) + self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(70, 10), + ReadHoldingRegisters(86, 3), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3016), + 3061, + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return super().pack_polling_commands + [ + ReadHoldingRegisters(105, 16), + ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500p.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500p.py new file mode 100644 index 0000000..f70d906 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500p.py @@ -0,0 +1,63 @@ +"""EP500P fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class EP500P(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "EP500P", sn) + + # Details (matches AC500 + AC300) + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_uint_field("internal_ac_voltage", 71) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_uint_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 1) + self.struct.add_decimal_field("internal_current_two", 75, 1) + self.struct.add_uint_field("internal_power_two", 76) + self.struct.add_decimal_field("ac_input_voltage", 77, 1) + self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100)) + self.struct.add_uint_field("internal_power_three", 79) + self.struct.add_decimal_field("ac_input_frequency", 80, 2) + self.struct.add_uint_field("internal_dc_input_voltage", 86) + self.struct.add_decimal_field("internal_dc_input_power", 87, 1) + self.struct.add_decimal_field("internal_dc_input_current", 88, 2) + + # Battery packs (matches AC500) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls (matches AC500 + AC300) + self.struct.add_enum_field("ups_mode", 3001, UpsMode) + self.struct.add_bool_field("split_phase_on", 3004) + self.struct.add_enum_field( + "split_phase_machine_mode", 3005, SplitPhaseMachineType + ) + self.struct.add_bool_field("grid_charge_on", 3011) + self.struct.add_bool_field("time_control_on", 3013) + self.struct.add_uint_field("battery_range_start", 3015) + self.struct.add_uint_field("battery_range_end", 3016) + self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(70, 10), + ReadHoldingRegisters(86, 3), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3016), + 3061, + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return super().pack_polling_commands + [ + ReadHoldingRegisters(105, 16), + ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep600.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep600.py new file mode 100644 index 0000000..047ce97 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep600.py @@ -0,0 +1,114 @@ +"""EP600 fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..base_devices.ProtocolV2Device import ProtocolV2Device + + +class EP600(ProtocolV2Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "EP600", sn) + + # Details + self.struct.add_uint_field("battery_range_start", 2022) + self.struct.add_uint_field("battery_range_end", 2023) + self.struct.add_uint_field("max_ac_input_power", 2213) + self.struct.add_uint_field("max_ac_input_current", 2214) + self.struct.add_uint_field("max_ac_output_power", 2215) + self.struct.add_uint_field("max_ac_output_current", 2216) + + # DC Solar + self.struct.add_uint_field("pv_input_power1", 1212) + self.struct.add_uint_field("pv_input_voltage1", 1213) + self.struct.add_uint_field("pv_input_current1", 1214) + self.struct.add_uint_field("pv_input_power2", 1220) + self.struct.add_uint_field("pv_input_voltage2", 1221) + self.struct.add_uint_field("pv_input_current2", 1222) + + # ADL400 Smart Meter for AC Solar + self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228) + self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236) + self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244) + self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1) + self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1) + self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1) + # Testing required + self.struct.add_uint_field("adl400_ac_input_current_phase1", 1230) + self.struct.add_uint_field("adl400_ac_input_current_phase2", 1238) + self.struct.add_uint_field("adl400_ac_input_current_phase3", 1246) + + # Grid Input + self.struct.add_decimal_field("grid_frequency", 1300, 1) + self.struct.add_uint_field("grid_power_phase1", 1313) + self.struct.add_uint_field("grid_power_phase2", 1319) + self.struct.add_uint_field("grid_power_phase3", 1325) + self.struct.add_decimal_field("grid_voltage_phase1", 1314, 1) + self.struct.add_decimal_field("grid_voltage_phase2", 1320, 1) + self.struct.add_decimal_field("grid_voltage_phase3", 1326, 1) + self.struct.add_decimal_field("grid_current_phase1", 1315, 1) + self.struct.add_decimal_field("grid_current_phase2", 1321, 1) + self.struct.add_decimal_field("grid_current_phase3", 1327, 1) + + # EP600 AC Output + self.struct.add_decimal_field("ac_output_frequency", 1500, 1) + self.struct.add_uint_field("ac_output_power_phase1", 1510) + self.struct.add_uint_field("ac_output_power_phase2", 1517) + self.struct.add_uint_field("ac_output_power_phase3", 1524) + self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1) + self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1) + self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1) + self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1) + self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1) + self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1) + + # Statistics + self.struct.add_decimal_field( + "total_ac_consumption", 152, 1 + ) # Load consumption + self.struct.add_decimal_field( + "total_grid_consumption", 156, 1 + ) # Grid consumption stats + self.struct.add_decimal_field("total_grid_feed", 158, 1) + + # Battery packs + self.struct.add_swap_string_field("battery_type", 6101, 6) # internal + self.struct.add_sn_field("battery_serial_number", 6107) # internal + self.struct.add_version_field("bcu_version", 6175) # internal + self.struct.add_version_field("bmu_version", 6178) # internal + self.struct.add_version_field("safety_module_version", 6181) # internal + self.struct.add_version_field("high_voltage_module_version", 6184) # internal + + # @property + # def pack_num_max(self): + # return 16 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return super().polling_commands + [ + ReadHoldingRegisters(2022, 2), + ReadHoldingRegisters(2213, 5), + ReadHoldingRegisters(1212, 3), + ReadHoldingRegisters(1220, 3), + ReadHoldingRegisters(1228, 3), + ReadHoldingRegisters(1236, 3), + ReadHoldingRegisters(1244, 3), + ReadHoldingRegisters(1300, 1), + ReadHoldingRegisters(1313, 3), + ReadHoldingRegisters(1319, 3), + ReadHoldingRegisters(1325, 3), + ReadHoldingRegisters(1500, 1), + ReadHoldingRegisters(1510, 3), + ReadHoldingRegisters(1517, 3), + ReadHoldingRegisters(1524, 3), + ReadHoldingRegisters(152, 1), + ReadHoldingRegisters(156, 1), + ReadHoldingRegisters(158, 1), + ] + + # @property + # def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + # return super().pack_polling_commands + [ + # ReadHoldingRegisters(6101, 10), + # ReadHoldingRegisters(6175, 12), + # ] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep760.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep760.py new file mode 100644 index 0000000..b7126f7 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep760.py @@ -0,0 +1,8 @@ +"""EP760 fields.""" + +from ..base_devices.ProtocolV2Device import ProtocolV2Device + + +class EP760(ProtocolV2Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "EP760", sn) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/exceptions.py b/custom_components/bluetti_bt/bluetti_bt_lib/exceptions.py new file mode 100644 index 0000000..ad81536 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/exceptions.py @@ -0,0 +1,15 @@ +"""Exceptions.""" + +# Modified copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/bluetooth/exc.py + + +class ParseError(Exception): + pass + + +class ModbusError(Exception): + pass + + +class BadConnectionError(Exception): + pass diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/field_attributes.py b/custom_components/bluetti_bt/bluetti_bt_lib/field_attributes.py new file mode 100644 index 0000000..5cec159 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/field_attributes.py @@ -0,0 +1,319 @@ +"""Field attributes for device fields.""" + +from enum import Enum, auto, unique + +from .field_enums import ( + AutoSleepMode, + ChargingMode, + EcoShutdown, + LedMode, + OutputMode, + SplitPhaseMachineType, + UpsMode, +) + + +@unique +class FieldType(Enum): + NUMERIC = auto() + BOOL = auto() + ENUM = auto() + BUTTON = auto() + + +class FieldAttributes: + def __init__( + self, + type: FieldType = FieldType.NUMERIC, + setter: bool = False, + name: str = "", + unit_of_measurement: str | None = None, + device_class: str | None = None, + state_class: str | None = None, + options: Enum | None = None, + ): + self.type = type + self.setter = setter + self.name = name + self.unit_of_measurement = unit_of_measurement + self.device_class = device_class + self.state_class = state_class + self.options = options + + +class PowerFieldAttributes(FieldAttributes): + def __init__( + self, + name: str = "", + ): + super().__init__( + name=name, + unit_of_measurement="W", + device_class="power", + state_class="measurement", + ) + + +class VoltageFieldAttributes(FieldAttributes): + def __init__( + self, + name: str = "", + ): + super().__init__( + name=name, + unit_of_measurement="V", + device_class="voltage", + state_class="measurement", + ) + + +class CurrentFieldAttributes(FieldAttributes): + def __init__( + self, + name: str = "", + ): + super().__init__( + name=name, + unit_of_measurement="A", + device_class="current", + state_class="measurement", + ) + + +class EnergyFieldAttributes(FieldAttributes): + def __init__( + self, + name: str = "", + ): + super().__init__( + name=name, + unit_of_measurement="kWh", + device_class="energy", + state_class="total_increasing", + ) + + +class FrequencyFieldAttributes(FieldAttributes): + def __init__( + self, + name: str = "", + ): + super().__init__( + name=name, + unit_of_measurement="Hz", + device_class="frequency", + state_class="measurement", + ) + + +class OutletFieldAttributes(FieldAttributes): + def __init__( + self, + name: str = "", + setter: bool = False, + ): + super().__init__( + type=FieldType.BOOL, + name=name, + setter=setter, + device_class="outlet", + ) + + +FIELD_ATTRIBUTES: dict[str, FieldAttributes] = { + # Base device fields + "dc_input_power": PowerFieldAttributes("DC Input Power"), + "ac_input_power": PowerFieldAttributes("AC Input Power"), + "ac_output_power": PowerFieldAttributes("AC Output Power"), + "dc_output_power": PowerFieldAttributes("DC Output Power"), + "power_generation": EnergyFieldAttributes("Total Power Generation"), + "total_battery_percent": FieldAttributes( + type=FieldType.NUMERIC, + name="Total Battery Percent", + unit_of_measurement="%", + device_class="battery", + state_class="measurement", + ), + "total_battery_voltage": VoltageFieldAttributes("Total Battery Voltage"), + "ac_output_on": OutletFieldAttributes("AC Output"), + "dc_output_on": OutletFieldAttributes("DC Output"), + # Controls + "ac_output_on_switch": OutletFieldAttributes( + name="AC Output", + setter=True, + ), + "dc_output_on_switch": OutletFieldAttributes( + name="DC Output", + setter=True, + ), + # Device specific fields + "ac_output_mode": FieldAttributes( + type=FieldType.ENUM, + name="AC Output Mode", + options=OutputMode, + ), + "internal_ac_voltage": VoltageFieldAttributes("Internal AC Voltage"), + "internal_current_one": CurrentFieldAttributes("Internal Current Sensor 1"), + "internal_power_one": PowerFieldAttributes("Internal Power Sensor 1"), + "internal_ac_frequency": FrequencyFieldAttributes("Internal AC Frequency"), + "internal_current_two": CurrentFieldAttributes("Internal Current Sensor 2"), + "internal_power_two": PowerFieldAttributes("Internal Power Sensor 2"), + "ac_input_voltage": VoltageFieldAttributes("AC Input Voltage"), + "internal_current_three": CurrentFieldAttributes("Internal Current Sensor 3"), + "internal_power_three": PowerFieldAttributes("Internal Power Sensor 3"), + "ac_input_frequency": FrequencyFieldAttributes("AC Input Frequency"), + "internal_dc_input_voltage": VoltageFieldAttributes("Internal DC Input Voltage"), + "internal_dc_input_power": PowerFieldAttributes("Internal DC Input Power"), + "internal_dc_input_current": CurrentFieldAttributes("Internal DC Input Current"), + "pv_input_power1": PowerFieldAttributes("Solar Input Power 1"), + "pv_input_power2": PowerFieldAttributes("Solar Input Power 2"), + "pv_input_voltage1": VoltageFieldAttributes("Solar Input Voltage 1"), + "pv_input_voltage2": VoltageFieldAttributes("Solar Input Voltage 2"), + "pv_input_current1": CurrentFieldAttributes("Solar Input Current 1"), + "pv_input_current2": CurrentFieldAttributes("Solar Input Current 2"), + "adl400_ac_input_power_phase1": PowerFieldAttributes( + "AC Solar Input Power Phase 1" + ), + "adl400_ac_input_power_phase2": PowerFieldAttributes( + "AC Solar Input Power Phase 2" + ), + "adl400_ac_input_power_phase3": PowerFieldAttributes( + "AC Solar Input Power Phase 3" + ), + "adl400_ac_input_voltage_phase1": VoltageFieldAttributes( + "AC Solar Input Voltage Phase 1" + ), + "adl400_ac_input_voltage_phase2": VoltageFieldAttributes( + "AC Solar Input Voltage Phase 2" + ), + "adl400_ac_input_voltage_phase3": VoltageFieldAttributes( + "AC Solar Input Voltage Phase 3" + ), + "adl400_ac_input_current_phase1": CurrentFieldAttributes( + "AC Solar Input Current Phase 1" + ), + "adl400_ac_input_current_phase2": CurrentFieldAttributes( + "AC Solar Input Current Phase 2" + ), + "adl400_ac_input_current_phase3": CurrentFieldAttributes( + "AC Solar Input Current Phase 3" + ), + "grid_frequency": FrequencyFieldAttributes("Grid Frequency"), + "grid_power_phase1": PowerFieldAttributes("Grid Power Phase 1"), + "grid_power_phase2": PowerFieldAttributes("Grid Power Phase 2"), + "grid_power_phase3": PowerFieldAttributes("Grid Power Phase 3"), + "grid_voltage_phase1": VoltageFieldAttributes("Grid Voltage Phase 1"), + "grid_voltage_phase2": VoltageFieldAttributes("Grid Voltage Phase 2"), + "grid_voltage_phase3": VoltageFieldAttributes("Grid Voltage Phase 3"), + "grid_current_phase1": CurrentFieldAttributes("Grid Current Phase 1"), + "grid_current_phase2": CurrentFieldAttributes("Grid Current Phase 2"), + "grid_current_phase3": CurrentFieldAttributes("Grid Current Phase 3"), + "ac_output_frequency": FrequencyFieldAttributes("AC Output Frequency"), + "ac_output_power_phase1": PowerFieldAttributes("AC Output Power Phase 1"), + "ac_output_power_phase2": PowerFieldAttributes("AC Output Power Phase 2"), + "ac_output_power_phase3": PowerFieldAttributes("AC Output Power Phase 3"), + "ac_output_voltage_phase1": VoltageFieldAttributes("AC Output Voltage Phase 1"), + "ac_output_voltage_phase2": VoltageFieldAttributes("AC Output Voltage Phase 2"), + "ac_output_voltage_phase3": VoltageFieldAttributes("AC Output Voltage Phase 3"), + "total_ac_consumption": EnergyFieldAttributes("Total Load Consumption"), + "total_grid_consumption": EnergyFieldAttributes("Total Grid Consumption"), + "total_grid_feed": EnergyFieldAttributes("Total Grid Feed"), + # Device specific controls + "power_off": FieldAttributes( + type=FieldType.BUTTON, + setter=True, + name="Power Off", + ), + "auto_sleep_mode": FieldAttributes( + type=FieldType.ENUM, + setter=True, + name="Screen Auto Sleep Mode", + options=AutoSleepMode, + ), + "ups_mode": FieldAttributes( + type=FieldType.ENUM, + setter=False, # Disabled for safety reasons + name="UPS Working Mode", + options=UpsMode, + ), + "split_phase_on": FieldAttributes( + type=FieldType.BOOL, + setter=False, # Disabled for safety reasons + name="Split Phase", + ), + "split_phase_machine_mode": FieldAttributes( + type=FieldType.ENUM, + setter=False, # Disabled for safety reasons + name="Split Phase Machine Type", + options=SplitPhaseMachineType, + ), + "grid_charge_on": FieldAttributes( + type=FieldType.BOOL, + setter=False, # Disabled for safety reasons + name="Grid Charge", + ), + "time_control_on": FieldAttributes( + type=FieldType.BOOL, + setter=False, # Disabled for safety reasons + name="Time Control", + ), + "battery_range_start": FieldAttributes( + type=FieldType.NUMERIC, + setter=False, # Disabled + name="Battery Range Start", + unit_of_measurement="%", + ), + "battery_range_end": FieldAttributes( + type=FieldType.NUMERIC, + setter=False, # Disabled + name="Battery Range End", + unit_of_measurement="%", + ), + "led_mode": FieldAttributes( + type=FieldType.ENUM, + setter=True, + name="LED Mode", + options=LedMode, + ), + "eco_on": FieldAttributes( + type=FieldType.BOOL, + setter=True, + name="Eco Mode", + ), + "eco_shutdown": FieldAttributes( + type=FieldType.ENUM, + setter=True, + name="Eco Shutdown", + options=EcoShutdown, + ), + "charging_mode": FieldAttributes( + type=FieldType.ENUM, + setter=True, + name="Charging Mode", + options=ChargingMode, + ), + "power_lifting_on": FieldAttributes( + type=FieldType.BOOL, + setter=False, # Disabled for safety reasons + name="Power Lifting", + ), +} + + +def PACK_FIELD_ATTRIBUTES(pack: int): + return { + "pack_voltage": VoltageFieldAttributes( + name=f"Battery Pack {pack} Voltage", + ), + "pack_battery_percent": FieldAttributes( + type=FieldType.NUMERIC, + name=f"Battery Pack {pack} Percent", + unit_of_measurement="%", + device_class="battery", + state_class="measurement", + ), + "pack_bms_version": FieldAttributes( + type=FieldType.NUMERIC, + name=f"Battery Pack {pack} BMS Version", + ), + } diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/field_enums.py b/custom_components/bluetti_bt/bluetti_bt_lib/field_enums.py new file mode 100644 index 0000000..e74b22a --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/field_enums.py @@ -0,0 +1,57 @@ +"""Enums for fields.""" + +from enum import Enum, unique + + +@unique +class OutputMode(Enum): + STOP = 0 + INVERTER_OUTPUT = 1 + BYPASS_OUTPUT_C = 2 + BYPASS_OUTPUT_D = 3 + LOAD_MATCHING = 4 + + +@unique +class AutoSleepMode(Enum): + THIRTY_SECONDS = 2 + ONE_MINUTE = 3 + FIVE_MINUTES = 4 + NEVER = 5 + + +@unique +class UpsMode(Enum): + CUSTOMIZED = 1 + PV_PRIORITY = 2 + STANDARD = 3 + TIME_CONTROL = 4 + + +@unique +class SplitPhaseMachineType(Enum): + SLAVE = 0 + MASTER = 1 + + +@unique +class LedMode(Enum): + LOW = 1 + HIGH = 2 + SOS = 3 + OFF = 4 + + +@unique +class EcoShutdown(Enum): + ONE_HOUR = 1 + TWO_HOURS = 2 + THREE_HOURS = 3 + FOUR_HOURS = 4 + + +@unique +class ChargingMode(Enum): + STANDARD = 0 + SILENT = 1 + TURBO = 2 diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/utils/commands.py b/custom_components/bluetti_bt/bluetti_bt_lib/utils/commands.py new file mode 100644 index 0000000..e79191b --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/commands.py @@ -0,0 +1,105 @@ +"""Device commands.""" + +# Copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/commands.py + +import struct +import crcmod.predefined + +modbus_crc = crcmod.predefined.mkCrcFun("modbus") + + +class DeviceCommand: + def __init__(self, function_code: int, data: bytes): + self.function_code = function_code + + self.cmd = bytearray(len(data) + 4) + self.cmd[0] = 1 # MODBUS address + self.cmd[1] = function_code + self.cmd[2:-2] = data + struct.pack_into(" int: + """Returns the expected response size in bytes""" + pass + + def __iter__(self): + """Provide an iter implemention so that bytes(cmd) works""" + return iter(self.cmd) + + def is_exception_response(self, response: bytes): + """Checks the response code to see if it's a MODBUS exception""" + if len(response) < 2: + return False + else: + return response[1] == self.function_code + 0x80 + + def is_valid_response(self, response: bytes): + """Validates that the reponse is complete and uncorrupted""" + if len(response) < 3: + return False + + crc = modbus_crc(response[0:-2]) + crc_bytes = crc.to_bytes(2, byteorder="little") + return response[-2:] == crc_bytes + + def parse_response(self, response: bytes): + """Returns the raw body of the response""" + return response + + +class ReadHoldingRegisters(DeviceCommand): + def __init__(self, starting_address: int, quantity: int): + self.starting_address = starting_address + self.quantity = quantity + + super().__init__(3, struct.pack("!HH", starting_address, quantity)) + + def response_size(self): + # 3 byte header + # each returned field is actually 2 bytes (16-bit word) + # 2 byte crc + return 2 * self.quantity + 5 + + def parse_response(self, response: bytes): + return bytes(response[3:-2]) + + def __repr__(self): + return f"ReadHoldingRegisters(starting_address={self.starting_address}, quantity={self.quantity})" + + +class WriteSingleRegister(DeviceCommand): + def __init__(self, address: int, value: int): + self.address = address + self.value = value + + super().__init__(6, struct.pack("!HH", address, value)) + + def response_size(self): + return 8 + + def parse_response(self, response: bytes): + return bytes(response[4:6]) + + def __repr__(self): + return f"WriteSingleRegister(address={self.address}, value={self.value:#04x})" + + +class WriteMultipleRegisters(DeviceCommand): + def __init__(self, starting_address: int, data: bytes): + if len(data) % 2 != 0: + raise ValueError("data size must be multiple of 2") + + self.starting_address = starting_address + self.data = data + + body = bytearray(len(data) + 5) + half_len = len(data) >> 1 + struct.pack_into("!HHB", body, 0, starting_address, half_len, len(data)) + body[5:] = data + super().__init__(16, body) + + def response_size(self): + return 8 + + def __repr__(self): + return f"WriteMultipleRegisters(starting_address={self.starting_address}, data={self.data})" diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/utils/device_builder.py b/custom_components/bluetti_bt/bluetti_bt_lib/utils/device_builder.py new file mode 100644 index 0000000..1d3856d --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/device_builder.py @@ -0,0 +1,46 @@ +"""Device builder functions.""" + +import re + +from ..devices.ac60 import AC60 +from ..devices.ac200m import AC200M +from ..devices.ac300 import AC300 +from ..devices.ac500 import AC500 +from ..devices.eb3a import EB3A +from ..devices.ep500 import EP500 +from ..devices.ep500p import EP500P +from ..devices.ep600 import EP600 +from ..devices.ep760 import EP760 + +DEVICE_NAME_RE = re.compile( + r"^(AC60|AC200M|AC300|AC500|EB3A|EP500|EP500P|EP600|EP760)(\d+)$" +) + + +def build_device(address: str, name: str): + match = DEVICE_NAME_RE.match(name) + if match[1] == "AC60": + return AC60(address, match[2]) + if match[1] == "AC200M": + return AC200M(address, match[2]) + if match[1] == "AC300": + return AC300(address, match[2]) + if match[1] == "AC500": + return AC500(address, match[2]) + if match[1] == "EB3A": + return EB3A(address, match[2]) + if match[1] == "EP500": + return EP500(address, match[2]) + if match[1] == "EP500P": + return EP500P(address, match[2]) + if match[1] == "EP600": + return EP600(address, match[2]) + if match[1] == "EP760": + return EP760(address, match[2]) + + +def get_type_by_bt_name(bt_name: str): + match = DEVICE_NAME_RE.match(bt_name) + if match is None or len(match) < 2: + return "Unknown" + return match[1] diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/utils/struct.py b/custom_components/bluetti_bt/bluetti_bt_lib/utils/struct.py new file mode 100644 index 0000000..ea9418f --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/struct.py @@ -0,0 +1,186 @@ +"""Struct definitions.""" + +# Copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/struct.py + +from decimal import Decimal +from enum import Enum +import struct +from typing import Any, List, Optional, Tuple, Type + + +def swap_bytes(data: bytes): + """Swaps the place of every other byte, returning a new byte array""" + arr = bytearray(data) + for i in range(0, len(arr) - 1, 2): + arr[i], arr[i + 1] = arr[i + 1], arr[i] + return arr + + +class DeviceField: + def __init__(self, name: str, address: int, size: int): + self.name = name + self.address = address + self.size = size + + def parse(self, data: bytes) -> Any: + raise NotImplementedError + + def in_range(self, val: Any) -> bool: + return True + + +class UintField(DeviceField): + def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]]): + self.range = range + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> int: + return struct.unpack("!H", data)[0] + + def in_range(self, val: int) -> bool: + if self.range is None: + return True + else: + return val >= self.range[0] and val <= self.range[1] + + +class BoolField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> bool: + return struct.unpack("!H", data)[0] == 1 + + +class EnumField(DeviceField): + def __init__(self, name: str, address: int, enum: Type[Enum]): + self.enum = enum + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Any: + val = struct.unpack("!H", data)[0] + return self.enum(val) + + +class DecimalField(DeviceField): + def __init__( + self, name: str, address: int, scale: int, range: Optional[Tuple[int, int]] + ): + self.scale = scale + self.range = range + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Decimal: + val = Decimal(struct.unpack("!H", data)[0]) + return val / 10 ** self.scale + + def in_range(self, val: Decimal) -> bool: + if self.range is None: + return True + else: + return val >= self.range[0] and val <= self.range[1] + + +class DecimalArrayField(DeviceField): + def __init__(self, name: str, address: int, size: int, scale: int): + self.scale = scale + super().__init__(name, address, size) + + def parse(self, data: bytes) -> Decimal: + values = list(struct.unpack(f"!{self.size}H", data)) + return [Decimal(v) / 10 ** self.scale for v in values] + + +class StringField(DeviceField): + """Fixed-width null-terminated string field""" + + def parse(self, data: bytes) -> str: + return data.rstrip(b"\0").decode("ascii") + + +class SwapStringField(DeviceField): + """Fixed-width null-terminated string field""" + + def parse(self, data: bytes) -> str: + return swap_bytes(data).rstrip(b"\0").decode("ascii") + + +class VersionField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 2) + + def parse(self, data: bytes) -> int: + values = struct.unpack("!2H", data) + return Decimal(values[0] + (values[1] << 16)) / 100 + + +class SerialNumberField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 4) + + def parse(self, data: bytes) -> int: + values = struct.unpack("!4H", data) + return values[0] + (values[1] << 16) + (values[2] << 32) + (values[3] << 48) + + +class DeviceStruct: + fields: List[DeviceField] + + def __init__(self): + self.fields = [] + + def add_uint_field(self, name: str, address: int, range: Tuple[int, int] = None): + self.fields.append(UintField(name, address, range)) + + def add_bool_field(self, name: str, address: int): + self.fields.append(BoolField(name, address)) + + def add_enum_field(self, name: str, address: int, enum: Type[Enum]): + self.fields.append(EnumField(name, address, enum)) + + def add_decimal_field( + self, name: str, address: int, scale: int, range: Tuple[int, int] = None + ): + self.fields.append(DecimalField(name, address, scale, range)) + + def add_decimal_array_field(self, name: str, address: int, size: int, scale: int): + self.fields.append(DecimalArrayField(name, address, size, scale)) + + def add_string_field(self, name: str, address: int, size: int): + self.fields.append(StringField(name, address, size)) + + def add_swap_string_field(self, name: str, address: int, size: int): + self.fields.append(SwapStringField(name, address, size)) + + def add_version_field(self, name: str, address: int): + self.fields.append(VersionField(name, address)) + + def add_sn_field(self, name: str, address: int): + self.fields.append(SerialNumberField(name, address)) + + def parse(self, starting_address: int, data: bytes) -> dict: + # Offsets and size are counted in 2 byte chunks, so for the range we + # need to divide the byte size by 2 + data_size = int(len(data) / 2) + + # Filter out fields not in range + r = range(starting_address, starting_address + data_size) + fields = [ + f for f in self.fields if f.address in r and f.address + f.size - 1 in r + ] + + # Parse fields + parsed = {} + for f in fields: + data_start = 2 * (f.address - starting_address) + field_data = data[data_start : data_start + 2 * f.size] + val = f.parse(field_data) + + # Skip if the value is "out-of-range" - sometimes the sensors + # report weird values + if not f.in_range(val): + continue + + parsed[f.name] = val + + return parsed diff --git a/custom_components/bluetti_bt/config_flow.py b/custom_components/bluetti_bt/config_flow.py index 9ebd5b8..0d20d13 100644 --- a/custom_components/bluetti_bt/config_flow.py +++ b/custom_components/bluetti_bt/config_flow.py @@ -4,6 +4,8 @@ from __future__ import annotations import re import logging +from typing import Any +from bleak import BleakClient import voluptuous as vol @@ -12,11 +14,13 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, ) -from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector -from . import get_type_by_bt_name +from .bluetti_bt_lib.utils.device_builder import get_type_by_bt_name +from .bluetti_bt_lib.bluetooth.device_recognizer import recognize_device + from .const import ( CONF_MAX_RETRIES, CONF_PERSISTENT_CONN, @@ -24,7 +28,6 @@ from .const import ( CONF_POLLING_TIMEOUT, CONF_USE_CONTROLS, DOMAIN, - SUPPORTED_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -44,6 +47,14 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle bluetooth discovery.""" await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() + + # Get device type if needed + if isinstance(discovery_info.name, str) and discovery_info.name.startswith("PBOX"): + bleak_device = BleakClient(discovery_info.device) + device_type = await recognize_device(bleak_device, self.hass.loop.create_future) + _LOGGER.info("Device identified as %s", device_type) + discovery_info.name = discovery_info.name.replace("PBOX", device_type.strip()) + self._discovery_info = discovery_info self.context["title_placeholders"] = {"name": discovery_info.name} return await self.async_step_user() @@ -58,18 +69,12 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): discovery_info = self._discovered_devices[address] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() - dev_type = get_type_by_bt_name(discovery_info.name) name = re.sub("[^A-Z0-9]+", "", discovery_info.name) - if dev_type == "Unknown": - dev_type = user_input[CONF_TYPE] - name = name.replace("PBOX", dev_type) - return self.async_create_entry( title=name, data={ CONF_ADDRESS: discovery_info.address, - CONF_TYPE: dev_type, CONF_NAME: name, }, ) @@ -95,12 +100,6 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for service_info in self._discovered_devices.values() } ), - vol.Optional(CONF_TYPE): vol.In( - { - model: model - for model in SUPPORTED_MODELS - } - ), } ) return self.async_show_form( diff --git a/custom_components/bluetti_bt/const.py b/custom_components/bluetti_bt/const.py index 7015358..e6246f1 100644 --- a/custom_components/bluetti_bt/const.py +++ b/custom_components/bluetti_bt/const.py @@ -1,9 +1,5 @@ """Constants for the Bluetti BT integration.""" -import re - -from bluetti_mqtt.mqtt_client import MqttFieldConfig, MqttFieldType - DOMAIN = "bluetti_bt" MANUFACTURER = "Bluetti" @@ -28,7 +24,6 @@ SUPPORTED_MODELS = [ "EP600", "EP760", ] -DEVICE_NAME_RE = re.compile(r'^(AC200M|AC300|AC500|AC60|EP500P|EP500|EP600|EP760|EB3A)(\d+)$') CONTROL_FIELDS = [ "ac_output_on", @@ -43,509 +38,8 @@ DIAGNOSTIC_FIELDS = [ "bcu_version", "safety_module_version", "high_voltage_module_version", + "power_generation", + "total_ac_consumption", + "total_grid_consumption", + "total_grid_feed", ] - -ADDITIONAL_DEVICE_FIELDS = { - "max_ac_input_power": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Max AC Input Power per Phase", - }, - ), - "max_ac_input_current": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Max AC Input Current per Phase", - }, - ), - "max_ac_output_power": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Max AC Output Power per Phase", - }, - ), - "max_ac_output_current": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Max AC Output Current per Phase", - }, - ), - "bcu_version": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={"name": "BCU Version"}, - ), - "safety_module_version": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={"name": "Safety Module Version"}, - ), - "high_voltage_module_version": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={"name": "High Voltage Safety Module Version"}, - ), - 'pv_input_power1': MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Solar Input Power 1", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - 'pv_input_voltage1': MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Solar Input Voltage 1", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - 'pv_input_current1': MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Solar Input Current 1", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - 'pv_input_power2': MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Solar Input Power 2", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - 'pv_input_voltage2': MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Solar Input Voltage 2", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - 'pv_input_current2': MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Solar Input Current 2", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "adl400_ac_input_power_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "ADL400 AC Input Power Phase 1", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "adl400_ac_input_power_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "ADL400 AC Input Power Phase 2", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "adl400_ac_input_power_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "ADL400 AC Input Power Phase 3", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "adl400_ac_input_voltage_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "ADL400 AC Input Voltage Phase 1", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "adl400_ac_input_voltage_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "ADL400 AC Input Voltage Phase 2", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "adl400_ac_input_voltage_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "ADL400 AC Input Voltage Phase 3", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_voltage_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Voltage Phase 1", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_voltage_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Voltage Phase 2", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_voltage_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Voltage Phase 3", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_current_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Current Phase 1", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_current_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Current Phase 2", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_current_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Current Phase 3", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_power_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Power Phase 1", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_power_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Power Phase 2", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "grid_input_power_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Grid Input Power Phase 3", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_frequency": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Frequency", - "unit_of_measurement": "Hz", - "device_class": "frequency", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_voltage_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Voltage Phase 1", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_voltage_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Voltage Phase 2", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_voltage_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Voltage Phase 3", - "unit_of_measurement": "V", - "device_class": "voltage", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_power_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Power Phase 1", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_power_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Power Phase 2", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_power_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Power Phase 3", - "unit_of_measurement": "W", - "device_class": "power", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_current_phase1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Current Phase 1", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_current_phase2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Current Phase 2", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "ac_output_current_phase3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "AC Output Current Phase 3", - "unit_of_measurement": "A", - "device_class": "current", - "state_class": "measurement", - "force_update": True, - }, - ), - "testing0": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 0", - }, - ), - "testing1": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 1", - }, - ), - "testing2": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 2", - }, - ), - "testing3": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 3", - }, - ), - "testing4": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 4", - }, - ), - "testing5": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 5", - }, - ), - "testing6": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 6", - }, - ), - "testing7": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 7", - }, - ), - "testing8": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 8", - }, - ), - "testing9": MqttFieldConfig( - type=MqttFieldType.NUMERIC, - setter=False, - advanced=False, - home_assistant_extra={ - "name": "Register Testing 9", - }, - ), -} diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 03b1a2b..c85b4c8 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -1,14 +1,11 @@ """Coordinator for Bluetti integration.""" from __future__ import annotations -from typing import List, cast -import asyncio from datetime import timedelta import logging -import async_timeout -from bleak import BleakClient, BleakError +from bleak import BleakClient from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant @@ -16,17 +13,10 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from bluetti_mqtt.bluetooth.client import BluetoothClient -from bluetti_mqtt.bluetooth import ( - BadConnectionError, - ModbusError, - ParseError, -) -from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice -from bluetti_mqtt.core.commands import ReadHoldingRegisters +from .bluetti_bt_lib.bluetooth.device_reader import DeviceReader +from .bluetti_bt_lib.utils.device_builder import build_device -from .const import DATA_POLLING_RUNNING, DOMAIN -from .utils import mac_loggable, build_device +from .utils import mac_loggable _LOGGER = logging.getLogger(__name__) @@ -51,15 +41,6 @@ class PollingCoordinator(DataUpdateCoordinator): name="Bluetti polling coordinator", update_interval=timedelta(seconds=polling_interval), ) - self._address = address - self.has_notifier = False - self.notify_future = None - self.current_command = None - self.notify_response = bytearray() - self.bluetti_device = build_device(address, device_name) - self.persistent_conn = persistent_conn - self.polling_timeout = polling_timeout - self.max_retries = max_retries # Create client self.logger.debug("Creating client") @@ -67,10 +48,17 @@ class PollingCoordinator(DataUpdateCoordinator): if device is None: self.logger.error("Device %s not available", mac_loggable(address)) return None - self.client = BleakClient(device) + client = BleakClient(device) + bluetti_device = build_device(address, device_name) - # polling mutex to guard against switches - self.polling_lock = asyncio.Lock() + self.reader = DeviceReader( + client, + bluetti_device, + self.hass.loop.create_future, + persistent_conn=persistent_conn, + polling_timeout=polling_timeout, + max_retries=max_retries, + ) async def _async_update_data(self): """Fetch data from API endpoint. @@ -78,172 +66,5 @@ 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") - - if self.bluetti_device is None: - self.logger.error("Device type for %s not found", mac_loggable(self._address)) - return None - - parsed_data: dict = {} - - async with self.polling_lock: - try: - async with async_timeout.timeout(self.polling_timeout): - - # Reconnect if not connected - for attempt in range(1, self.max_retries + 1): - try: - if not self.client.is_connected: - await self.client.connect() - break - except Exception as e: - if attempt == self.max_retries or attempt == 1: - raise # pass exception on max_retries attempt - else: - self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...") - await asyncio.sleep(2) - - # Attach notifier if needed - if not self.has_notifier: - await self.client.start_notify( - BluetoothClient.NOTIFY_UUID, self._notification_handler - ) - self.has_notifier = True - - for command in self.bluetti_device.polling_commands: - try: - body = command.parse_response( - await self.async_send_command(command) - ) - self.logger.debug("Raw data: %s", body) - parsed = self.bluetti_device.parse( - command.starting_address, body - ) - self.logger.debug("Parsed data: %s", parsed) - parsed_data.update(parsed) - - except ParseError: - self.logger.warning("Got a parse exception...") - - if len(self.bluetti_device.pack_polling_commands) > 0: - self.logger.debug("Polling battery packs") - # pack polling - for pack in range (1, self.bluetti_device.pack_num_max + 1): - # Set current pack number - await self.async_send_command( - self.bluetti_device.build_setter_command('pack_num', pack) - ) - - # We need to wait after switching packs for the data to be available - await asyncio.sleep(5) - - for command in self.bluetti_device.pack_polling_commands: - # Request & parse result for each pack - try: - body = command.parse_response( - await self.async_send_command(command) - ) - parsed = self.bluetti_device.parse( - command.starting_address, body - ) - self.logger.debug("Parsed data: %s", parsed) - - pack_number = parsed.get('pack_num') - if not isinstance(pack_number, int) or pack_number != pack: - self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", pack_number, pack) - continue - - for key, value in parsed.items(): - parsed_data.update({key+str(pack):value}) - - except ParseError: - self.logger.warning("Got a parse exception...") - - except TimeoutError: - self.logger.warning("Polling timed out for device %s", mac_loggable(self._address)) - return None - except BleakError as err: - self.logger.warning("Bleak error: %s", err) - return None - finally: - # Disconnect if connection not persistant - if not self.persistent_conn: - if self.has_notifier: - await self.client.stop_notify(BluetoothClient.NOTIFY_UUID) - self.has_notifier = False - await self.client.disconnect() - - self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False - - # Pass data back to sensors - return parsed_data - - async def async_send_command(self, command: ReadHoldingRegisters) -> bytes: - """Send command and return response""" - try: - # Prepare to make request - self.current_command = command - self.notify_future = self.hass.loop.create_future() - self.notify_response = bytearray() - - # Make request - self.logger.debug("Requesting %s", command) - await self.client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) - ) - - # Wait for response - res = await asyncio.wait_for( - self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT - ) - - # Process data - self.logger.debug("Got %s bytes", len(res)) - return cast(bytes, res) - - except TimeoutError: - self.logger.debug( - "Polling timed out (address: %s)", mac_loggable(self._address) - ) - except ModbusError as err: - self.logger.warning( - "Got an invalid request error for %s: %s", - command, - err, - ) - except (BadConnectionError, BleakError) as err: - self.logger.warning( - "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err - ) - - # caught an exception, return empty bytes object - return bytes() - - def _notification_handler(self, _sender: int, data: bytearray): - """Handle bt data.""" - - # Ignore notifications we don't expect - if not self.notify_future or self.notify_future.done(): - _LOGGER.warning("Unexpected notification") - return - - # If something went wrong, we might get weird data. - if data == b"AT+NAME?\r" or data == b"AT+ADV?\r": - err = BadConnectionError("Got AT+ notification") - self.notify_future.set_exception(err) - return - - # Save data - self.notify_response.extend(data) - - if len(self.notify_response) == self.current_command.response_size(): - if self.current_command.is_valid_response(self.notify_response): - self.notify_future.set_result(self.notify_response) - else: - self.notify_future.set_exception(ParseError("Failed checksum")) - elif self.current_command.is_exception_response(self.notify_response): - # We got a MODBUS command exception - msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}" - self.notify_future.set_exception(ModbusError(msg)) + return await self.reader.read_data() diff --git a/custom_components/bluetti_bt/devices/ep600.py b/custom_components/bluetti_bt/devices/ep600.py deleted file mode 100644 index 3ea91a7..0000000 --- a/custom_components/bluetti_bt/devices/ep600.py +++ /dev/null @@ -1,113 +0,0 @@ -"""EP600 Field definitions. - -Changes made here are only temporary and should also be -contributed to https://github.com/warhammerkid/bluetti_mqtt -""" - -from typing import List - -from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice -from bluetti_mqtt.core.devices.struct import DeviceStruct -from bluetti_mqtt.core.commands import ReadHoldingRegisters - -class EP600(BluettiDevice): - def __init__(self, address: str, sn: str): - self.struct = DeviceStruct() - - self.struct.add_uint_field('total_battery_percent', 102) - self.struct.add_swap_string_field('device_type', 110, 6) - self.struct.add_sn_field('serial_number', 116) - self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) - self.struct.add_swap_string_field('device_type', 1101, 6) - self.struct.add_sn_field('serial_number', 1107) - self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) - self.struct.add_uint_field('battery_range_start', 2022) - self.struct.add_uint_field('battery_range_end', 2023) - self.struct.add_uint_field('max_ac_input_power', 2213) - self.struct.add_uint_field('max_ac_input_current', 2214) - self.struct.add_uint_field('max_ac_output_power', 2215) - self.struct.add_uint_field('max_ac_output_current', 2216) - self.struct.add_swap_string_field('battery_type', 6101, 6) - self.struct.add_sn_field('battery_serial_number', 6107) - self.struct.add_version_field('bcu_version', 6175) - self.struct.add_version_field('bmu_version', 6178) - self.struct.add_version_field('safety_module_version', 6181) - self.struct.add_version_field('high_voltage_module_version', 6184) - - # DC Solar Input (copied from PR https://github.com/warhammerkid/bluetti_mqtt/pull/87 by KM011092) - self.struct.add_uint_field('pv_input_power1', 1212) # MPP 1 in - value * 0.1 - #self.struct.add_uint_field('pv_input_voltage1', 1213) # MPP 1 in - value * 0.1 - #self.struct.add_uint_field('pv_input_current1', 1214) # MPP 1 in - self.struct.add_uint_field('pv_input_power2', 1220) # MPP 2 in - value * 0.1 - #self.struct.add_uint_field('pv_input_voltage2', 1221) # MPP 2 in - value * 0.1 - #self.struct.add_uint_field('pv_input_current2', 1222) # MPP 2 in - - # ADL400 Smart Meter for AC Solar - self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228) - self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236) - self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244) - self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1) - self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1) - self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1) - - # Grid Input - self.struct.add_decimal_field("grid_input_frequency", 1300, 1) - self.struct.add_uint_field("grid_input_power_phase1", 1313) - self.struct.add_uint_field("grid_input_power_phase2", 1319) - self.struct.add_uint_field("grid_input_power_phase3", 1325) - self.struct.add_decimal_field("grid_input_voltage_phase1", 1314, 1) - self.struct.add_decimal_field("grid_input_voltage_phase2", 1320, 1) - self.struct.add_decimal_field("grid_input_voltage_phase3", 1326, 1) - self.struct.add_decimal_field("grid_input_current_phase1", 1315, 1) - self.struct.add_decimal_field("grid_input_current_phase2", 1321, 1) - self.struct.add_decimal_field("grid_input_current_phase3", 1327, 1) - - # EP600 AC Output - self.struct.add_decimal_field("ac_output_frequency", 1500, 1) - self.struct.add_uint_field("ac_output_power_phase1", 1510) - self.struct.add_uint_field("ac_output_power_phase2", 1517) - self.struct.add_uint_field("ac_output_power_phase3", 1524) - self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1) - self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1) - self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1) - self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1) - self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1) - self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1) - - super().__init__(address, 'EP600', sn) - - @property - def polling_commands(self) -> List[ReadHoldingRegisters]: - return [ - ReadHoldingRegisters(100, 62), - ReadHoldingRegisters(1228, 19), - ReadHoldingRegisters(1300, 28), - ReadHoldingRegisters(1500, 27), - ReadHoldingRegisters(2022, 6), - ReadHoldingRegisters(2213, 4), - ReadHoldingRegisters(1212, 11), - # Battery - ReadHoldingRegisters(6101, 7), - ReadHoldingRegisters(6175, 11), - ] - - @property - def pack_polling_commands(self) -> List[ReadHoldingRegisters]: - return [] - - @property - def logging_commands(self) -> List[ReadHoldingRegisters]: - return [ - ReadHoldingRegisters(100, 62), - ReadHoldingRegisters(1100, 51), - ReadHoldingRegisters(1200, 90), - ReadHoldingRegisters(1300, 31), - ReadHoldingRegisters(1400, 48), - ReadHoldingRegisters(1500, 30), - ReadHoldingRegisters(2000, 89), - ReadHoldingRegisters(2200, 41), - ReadHoldingRegisters(2300, 36), - ReadHoldingRegisters(6000, 32), - ReadHoldingRegisters(6100, 100), - ReadHoldingRegisters(6300, 100), - ] diff --git a/custom_components/bluetti_bt/devices/ep760.py b/custom_components/bluetti_bt/devices/ep760.py deleted file mode 100644 index 6028f23..0000000 --- a/custom_components/bluetti_bt/devices/ep760.py +++ /dev/null @@ -1,67 +0,0 @@ -"""EP760 Field definitions. - -Changes made here are only temporary and should also be -contributed to https://github.com/warhammerkid/bluetti_mqtt -""" - -from typing import List - -from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice -from bluetti_mqtt.core.devices.struct import DeviceStruct -from bluetti_mqtt.core.commands import ReadHoldingRegisters - -class EP760(BluettiDevice): - def __init__(self, address: str, sn: str): - self.struct = DeviceStruct() - - # TODO has to be tested - - self.struct.add_uint_field('total_battery_percent', 102) - self.struct.add_swap_string_field('device_type', 110, 6) - self.struct.add_sn_field('serial_number', 116) - self.struct.add_decimal_field('power_generation', 154, 1) - self.struct.add_swap_string_field('device_type', 1101, 6) - self.struct.add_sn_field('serial_number', 1107) - self.struct.add_decimal_field('power_generation', 1202, 1) - self.struct.add_uint_field('battery_range_start', 2022) - self.struct.add_uint_field('battery_range_end', 2023) - self.struct.add_uint_field('max_ac_input_power', 2213) - self.struct.add_uint_field('max_ac_input_current', 2214) - self.struct.add_uint_field('max_ac_output_power', 2215) - self.struct.add_uint_field('max_ac_output_current', 2216) - self.struct.add_swap_string_field('battery_type', 6101, 6) - self.struct.add_sn_field('battery_serial_number', 6107) - self.struct.add_version_field('bcu_version', 6175) - self.struct.add_version_field('bmu_version', 6178) - self.struct.add_version_field('safety_module_version', 6181) - self.struct.add_version_field('high_voltage_module_version', 6184) - - super().__init__(address, 'EP760', sn) - - @property - def polling_commands(self) -> List[ReadHoldingRegisters]: - return [ - ReadHoldingRegisters(100, 62), - ReadHoldingRegisters(2022, 2), - ] - - @property - def pack_polling_commands(self) -> List[ReadHoldingRegisters]: - return [] - - @property - def logging_commands(self) -> List[ReadHoldingRegisters]: - return [ - ReadHoldingRegisters(100, 62), - ReadHoldingRegisters(1100, 51), - ReadHoldingRegisters(1200, 90), - ReadHoldingRegisters(1300, 31), - ReadHoldingRegisters(1400, 48), - ReadHoldingRegisters(1500, 30), - ReadHoldingRegisters(2000, 89), - ReadHoldingRegisters(2200, 41), - ReadHoldingRegisters(2300, 36), - ReadHoldingRegisters(6000, 32), - ReadHoldingRegisters(6100, 100), - ReadHoldingRegisters(6300, 100), - ] diff --git a/custom_components/bluetti_bt/manifest.json b/custom_components/bluetti_bt/manifest.json index c79a35a..ff05ddb 100644 --- a/custom_components/bluetti_bt/manifest.json +++ b/custom_components/bluetti_bt/manifest.json @@ -19,6 +19,6 @@ "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", - "requirements": ["bluetti_mqtt==0.15.0"], + "requirements": [], "version": "0.0.16" } diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 09a81a7..d024a38 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -6,33 +6,25 @@ from enum import Enum import logging from decimal import Decimal -from homeassistant.components.sensor import SensorEntity, CONF_STATE_CLASS +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.const import ( CONF_ADDRESS, CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - CONF_DEVICE_CLASS, EntityCategory, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from bluetti_mqtt.mqtt_client import ( - NORMAL_DEVICE_FIELDS, - DC_INPUT_FIELDS, - MqttFieldType, - battery_pack_fields, -) +from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType +from .bluetti_bt_lib.utils.device_builder import build_device from . import device_info as dev_info, get_unique_id -from .const import DATA_COORDINATOR, DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS +from .const import DATA_COORDINATOR, DOMAIN, DIAGNOSTIC_FIELDS from .coordinator import PollingCoordinator -from .utils import unique_id_loggable, build_device +from .utils import unique_id_loggable _LOGGER = logging.getLogger(__name__) @@ -55,45 +47,43 @@ async def async_setup_entry( bluetti_device = build_device(address, device_name) sensors_to_add = [] - all_fields = NORMAL_DEVICE_FIELDS - all_fields.update(DC_INPUT_FIELDS) - all_fields.update(ADDITIONAL_DEVICE_FIELDS) + all_fields = FIELD_ATTRIBUTES if len(bluetti_device.pack_polling_commands) > 0: # add pack fields for device _LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max) for pack in range (1, bluetti_device.pack_num_max + 1): - for name, field in battery_pack_fields(pack).items(): + for name, field in PACK_FIELD_ATTRIBUTES(pack).items(): all_fields.update({name+str(pack): field}) for field_key, field_config in all_fields.items(): - if bluetti_device.has_field(field_key) or field_config.id_override is not None: + if bluetti_device.has_field(field_key): category = None if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS: category = EntityCategory.DIAGNOSTIC - if field_config.type == MqttFieldType.NUMERIC: + if field_config.type == FieldType.NUMERIC: sensors_to_add.append( BluettiSensor( hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], device_info, address, field_key, - field_config.home_assistant_extra.get(CONF_NAME, ""), - field_config.home_assistant_extra.get(CONF_UNIT_OF_MEASUREMENT), - field_config.home_assistant_extra.get(CONF_DEVICE_CLASS), - field_config.home_assistant_extra.get(CONF_STATE_CLASS), + field_config.name, + field_config.unit_of_measurement, + field_config.device_class, + field_config.state_class, category=category, ) ) - elif field_config.type == MqttFieldType.ENUM: + elif field_config.type == FieldType.ENUM: sensors_to_add.append( BluettiSensor( hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], device_info, address, field_key, - field_config.home_assistant_extra.get(CONF_NAME, ""), - options=field_config.home_assistant_extra.get(CONF_OPTIONS), + field_config.name, + options=[o.value for o in field_config.options], category=category, ) ) @@ -139,7 +129,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: + if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected: return if self.coordinator.data is None: diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py index 02fd57f..4481fdf 100644 --- a/custom_components/bluetti_bt/switch.py +++ b/custom_components/bluetti_bt/switch.py @@ -23,18 +23,15 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) -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 .bluetti_bt_lib.base_devices.BluettiDevice import BluettiDevice +from .bluetti_bt_lib.const import WRITE_UUID +from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType +from .bluetti_bt_lib.utils.device_builder import build_device from . import device_info as dev_info, get_unique_id -from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS +from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN from .coordinator import PollingCoordinator -from .utils import mac_loggable, unique_id_loggable, build_device +from .utils import mac_loggable, unique_id_loggable _LOGGER = logging.getLogger(__name__) @@ -57,12 +54,10 @@ async def async_setup_entry( bluetti_device = build_device(address, device_name) sensors_to_add = [] - all_fields = NORMAL_DEVICE_FIELDS - all_fields.update(DC_INPUT_FIELDS) - all_fields.update(ADDITIONAL_DEVICE_FIELDS) + all_fields = FIELD_ATTRIBUTES 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.type == FieldType.BOOL: if field_config.setter is True and field_key in CONTROL_FIELDS: sensors_to_add.append( BluettiSwitch( @@ -71,7 +66,7 @@ async def async_setup_entry( device_info, address, field_key, - field_config.home_assistant_extra.get(CONF_NAME, ""), + field_config.name, entry.entry_id ) ) @@ -98,8 +93,8 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): self._bluetti_device = bluetti_device self._coordinator = coordinator - self._client = coordinator.client - self._polling_lock = coordinator.polling_lock + self._client = coordinator.reader.client + self._polling_lock = coordinator.reader.polling_lock e_name = f"{device_info.get('name')} {name}" self._address = address self._response_key = response_key @@ -117,7 +112,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: + if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected: return _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) @@ -169,7 +164,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): # Send command _LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state) await self._client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) + WRITE_UUID, bytes(command) ) # Wait until device has changed value, otherwise reading register might reset it @@ -183,7 +178,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): return None finally: # Disconnect if connection not persistant - if not self._coordinator.persistent_conn: + if not self._coordinator.reader.persistent_conn: await self._client.disconnect() await self.coordinator.async_request_refresh() diff --git a/custom_components/bluetti_bt/utils.py b/custom_components/bluetti_bt/utils.py index a42f928..fce973c 100644 --- a/custom_components/bluetti_bt/utils.py +++ b/custom_components/bluetti_bt/utils.py @@ -1,11 +1,5 @@ """Tools.""" -from bluetti_mqtt.bluetooth import build_device as orig_build_device - -from .const import DEVICE_NAME_RE -from .devices.ep600 import EP600 -from .devices.ep760 import EP760 - def mac_loggable(mac: str) -> str: """Remove parts of the mac address for logging.""" splitted = mac.split(":") @@ -16,12 +10,3 @@ def unique_id_loggable(unique_id: str) -> str: splitted = unique_id.split("_", maxsplit=1) serial = splitted[0][:6] return serial + "XXXXXXXXXX" + splitted[1] - -def build_device(address: str, name: str): - match = DEVICE_NAME_RE.match(name) - if match[1] == 'EP760': - return EP760(address, match[2]) - elif match[1] == 'EP600': - return EP600(address, match[2]) - else: - return orig_build_device(address, name)