diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 4c8a762..1b20963 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -31,20 +31,10 @@ body: default: 0 validations: required: true - - type: dropdown + - type: textarea id: device attributes: label: What device are you seeing the problem on? - multiple: true - options: - - AC60 - - AC200M - - AC300 - - AC500 - - EB3A - - EP500 - - EP500P - - EP900 validations: required: true - type: dropdown @@ -55,6 +45,7 @@ body: options: - USB dongle - ESPHome bluetooth proxy + - Internal bluetooth adapter validations: required: true - type: dropdown diff --git a/README.md b/README.md index ce86d3d..1807142 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ 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. ## Installation To install this integration, you first need [HACS](https://hacs.xyz/) installed. @@ -14,9 +15,11 @@ 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 +- AC180 (basic data) +- AC180P (basic data) +- AC200L (untested) - AC200M - AC300 (tested) - AC500 @@ -24,9 +27,8 @@ After the installation, you can use this button to install the integration: - EP500 - EP500P - EP600 (tested) - -### 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)). +- EP760 (basic data) +- EP800 (basic data) ### Available controls: If enabled in the Integration options (you need to reload the integration if you change this option): diff --git a/custom_components/bluetti_bt/__init__.py b/custom_components/bluetti_bt/__init__.py index 8b69d58..8d68e05 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,13 +26,15 @@ 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__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Bluetti Powerstation from a config entry.""" + _LOGGER.debug("Init Bluetti BT Integration") + address = entry.data.get(CONF_ADDRESS) device_name = entry.data.get(CONF_NAME) use_controls = entry.data.get(CONF_USE_CONTROLS) @@ -52,10 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False) # Create coordinator for polling + _LOGGER.debug("Creating coordinator") coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator) + _LOGGER.debug("Creating entities") platforms: list = PLATFORMS if use_controls is True: _LOGGER.warning("You are using controls with this integration at your own risk!") @@ -64,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Setup platforms await hass.config_entries.async_forward_entry_setups(entry, platforms) + _LOGGER.debug("Setup done") + return True @@ -83,23 +90,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" - return dev_type diff --git a/custom_components/bluetti_bt/binary_sensor.py b/custom_components/bluetti_bt/binary_sensor.py index a52a269..0e09ee7 100644 --- a/custom_components/bluetti_bt/binary_sensor.py +++ b/custom_components/bluetti_bt/binary_sensor.py @@ -18,16 +18,12 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) -from bluetti_mqtt.bluetooth import build_device -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 .coordinator import PollingCoordinator, DummyDevice +from .const import DATA_COORDINATOR, DOMAIN, CONF_USE_CONTROLS +from .coordinator import PollingCoordinator from .utils import unique_id_loggable _LOGGER = logging.getLogger(__name__) @@ -40,6 +36,7 @@ async def async_setup_entry( device_name = entry.data.get(CONF_NAME) address = entry.data.get(CONF_ADDRESS) + use_controls = entry.data.get(CONF_USE_CONTROLS, False) if address is None: _LOGGER.error("Device has no address") @@ -49,18 +46,14 @@ async def async_setup_entry( # Add sensors according to device_info bluetti_device = build_device(address, device_name) - bluetti_device = DummyDevice(bluetti_device) sensors_to_add = [] - all_fields = NORMAL_DEVICE_FIELDS - all_fields.update(DC_INPUT_FIELDS) - all_fields.update(ADDITIONAL_DEVICE_FIELDS) + 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: - category = None + if field_config.type == FieldType.BOOL: if field_config.setter is True: - category = EntityCategory.DIAGNOSTIC + continue sensors_to_add.append( BluettiBinarySensor( @@ -68,8 +61,7 @@ async def async_setup_entry( device_info, address, field_key, - field_config.home_assistant_extra.get(CONF_NAME, ""), - category=category, + field_config.name, ) ) @@ -86,26 +78,30 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity): address, response_key: str, name: str, - category: EntityCategory | None = None, ): """Init battery entity.""" super().__init__(coordinator) + self._attr_has_entity_name = True e_name = f"{device_info.get('name')} {name}" self._address = address self._response_key = response_key self._attr_device_info = device_info - self._attr_name = e_name + self._attr_name = name self._attr_available = False self._attr_unique_id = get_unique_id(e_name) - self._attr_entity_category = category + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._attr_available @callback 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)) @@ -114,11 +110,13 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity): "Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id) ) self._attr_available = False + self.async_write_ha_state() return response_data = self.coordinator.data.get(self._response_key) if response_data is None: self._attr_available = False + self.async_write_ha_state() return if not isinstance(response_data, bool): @@ -128,6 +126,7 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity): response_data, ) self._attr_available = False + self.async_write_ha_state() return self._attr_available = True 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..0d1edae --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/BluettiDevice.py @@ -0,0 +1,70 @@ +"""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 [] + + @property + def pack_num_field(self) -> List[ReadHoldingRegisters]: + """The address 'range' of the pack num result. Matches pack_num_result""" + 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..8160342 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/ProtocolV1Device.py @@ -0,0 +1,64 @@ +"""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) + + # 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(48, 2), + ReadHoldingRegisters(3007, 2), + ] + + @property + def writable_ranges(self) -> List[range]: + return [range(3006, 3009)] + + @property + def pack_num_field(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(96, 1), + ] 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..e721ded --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/bluetooth/device_reader.py @@ -0,0 +1,215 @@ +"""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 and len(self.bluetti_device.pack_num_field) == 1: + _LOGGER.debug("Polling battery packs") + for pack in range(1, self.bluetti_device.pack_num_max + 1): + _LOGGER.debug("Setting pack_num to %i", pack) + + # Set current pack number + command = self.bluetti_device.build_setter_command( + "pack_num", pack + ) + body = command.parse_response( + await self._async_send_command(command) + ) + _LOGGER.debug("Raw data set: %s", body) + # b'\x00\x02' b'\x00\x03' here (seems like result) + + 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) + + for key, value in parsed.items(): + parsed_data.update({key + str(pack): value}) + + except ParseError: + _LOGGER.warning("Got a parse exception...") + + except TimeoutError: + _LOGGER.warning("Polling timed out. Trying again later") + 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: + try: + await self.client.stop_notify(NOTIFY_UUID) + except: + # Ignore errors here + pass + self.has_notifier = False + await self.client.disconnect() + + # Check if dict is empty + if not parsed_data: + return None + + 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.debug("Polling single command timed out") + except ModbusError as err: + _LOGGER.debug( + "Got an invalid request error for %s: %s", + command, + err, + ) + except (BadConnectionError, BleakError) as err: + # Ignore other errors + pass + + # 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/ac180.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac180.py new file mode 100644 index 0000000..565e3f8 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac180.py @@ -0,0 +1,8 @@ +"""AC180 fields.""" + +from ..base_devices.ProtocolV2Device import ProtocolV2Device + + +class AC180(ProtocolV2Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC180", sn) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac180p.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac180p.py new file mode 100644 index 0000000..eeb4236 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac180p.py @@ -0,0 +1,8 @@ +"""AC180P fields.""" + +from ..base_devices.ProtocolV2Device import ProtocolV2Device + + +class AC180P(ProtocolV2Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC180P", sn) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200l.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200l.py new file mode 100644 index 0000000..f4c16b5 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200l.py @@ -0,0 +1,60 @@ +"""AC200L fields.""" + +from typing import List + +from ..utils.commands import ReadHoldingRegisters +from ..field_enums import AutoSleepMode, OutputMode +from ..base_devices.ProtocolV1Device import ProtocolV1Device + + +class AC200L(ProtocolV1Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "AC200L", sn) + + # Details + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_decimal_field("internal_ac_voltage", 71, 1) + 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, 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_uint_field("pack_num_max", 91) # internal + self.struct.add_decimal_field("total_battery_voltage", 92, 1) + self.struct.add_uint_field("pack_num_result", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + 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, 5), + ReadHoldingRegisters(86, 3), + ReadHoldingRegisters(92, 1), + ReadHoldingRegisters(3060, 2), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [range(3060, 3062)] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + ReadHoldingRegisters(105, 16) + ] 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..fb0d97c --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac200m.py @@ -0,0 +1,60 @@ +"""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_decimal_field("internal_ac_voltage", 71, 1) + 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, 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_uint_field("pack_num_max", 91) # internal + self.struct.add_decimal_field("total_battery_voltage", 92, 1) + self.struct.add_uint_field("pack_num_result", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + 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, 5), + ReadHoldingRegisters(86, 3), + ReadHoldingRegisters(92, 1), + ReadHoldingRegisters(3060, 2), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [range(3060, 3062)] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + 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..e7a9303 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac300.py @@ -0,0 +1,84 @@ +"""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 + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_decimal_field("internal_ac_voltage", 71, 1) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_int_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 2) + self.struct.add_decimal_field("internal_current_two", 75, 1) + self.struct.add_int_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_int_field("internal_power_three", 79) + self.struct.add_decimal_field("ac_input_frequency", 80, 2) + self.struct.add_uint_field("pv_input_voltage1", 86, multiplier=.1) + self.struct.add_decimal_field("pv_input_power1", 87, 1, multiplier=10) + self.struct.add_decimal_field("pv_input_current1", 88, 2, multiplier=10) + + # 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_result", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + self.struct.add_version_field("pack_bms_version", 201) + + # Controls + 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, 11), + ReadHoldingRegisters(86, 3), + ReadHoldingRegisters(92, 1), + ReadHoldingRegisters(3001, 1), + ReadHoldingRegisters(3004, 2), + ReadHoldingRegisters(3011, 1), + ReadHoldingRegisters(3013, 1), + ReadHoldingRegisters(3015, 2), + ReadHoldingRegisters(3061, 1), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3017), + range(3061, 3062), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + 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..3159dde --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ac500.py @@ -0,0 +1,82 @@ +"""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 + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_decimal_field("internal_ac_voltage", 71, 1) + self.struct.add_decimal_field("internal_current_one", 72, 1) + self.struct.add_int_field("internal_power_one", 73) + self.struct.add_decimal_field("internal_ac_frequency", 74, 2) + self.struct.add_decimal_field("internal_current_two", 75, 1) + self.struct.add_int_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_int_field("internal_power_three", 79) + self.struct.add_decimal_field("ac_input_frequency", 80, 2) + self.struct.add_uint_field("pv_input_voltage1", 86, multiplier=.1) + self.struct.add_decimal_field("pv_input_power1", 87, 1, multiplier=10) + self.struct.add_decimal_field("pv_input_current1", 88, 2, multiplier=10) + + # 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_result", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls + 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, 11), + ReadHoldingRegisters(86, 3), + ReadHoldingRegisters(92, 1), + ReadHoldingRegisters(3001, 1), + ReadHoldingRegisters(3004, 2), + ReadHoldingRegisters(3011, 1), + ReadHoldingRegisters(3013, 1), + ReadHoldingRegisters(3015, 2), + ReadHoldingRegisters(3061, 1), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3017), + range(3061, 3062), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + 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..644ac26 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/eb3a.py @@ -0,0 +1,42 @@ +"""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), + ReadHoldingRegisters(3034, 1), + ReadHoldingRegisters(3060, 1), + ReadHoldingRegisters(3063, 5), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3034, 3035), + range(3060, 3061), + range(3063, 3067), + ] 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..1554ce9 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500.py @@ -0,0 +1,78 @@ +"""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 + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_decimal_field("internal_ac_voltage", 71, 1) + 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, 2) + 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_uint_field("pack_num_max", 91) # internal + self.struct.add_decimal_field("total_battery_voltage", 92, 1) + self.struct.add_uint_field("pack_num_result", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls + 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, 11), + ReadHoldingRegisters(86, 3), + ReadHoldingRegisters(92, 1), + ReadHoldingRegisters(3001, 1), + ReadHoldingRegisters(3004, 2), + ReadHoldingRegisters(3011, 1), + ReadHoldingRegisters(3013, 1), + ReadHoldingRegisters(3015, 2), + ReadHoldingRegisters(3061, 1), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3017), + range(3061, 3062), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + 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..ba19241 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep500p.py @@ -0,0 +1,78 @@ +"""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 + self.struct.add_enum_field("ac_output_mode", 70, OutputMode) + self.struct.add_decimal_field("internal_ac_voltage", 71, 1) + 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, 2) + 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_uint_field("pack_num_max", 91) # internal + self.struct.add_decimal_field("total_battery_voltage", 92, 1) + self.struct.add_uint_field("pack_num_result", 96) # internal + self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage + self.struct.add_uint_field("pack_battery_percent", 99) + self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal + + # Controls + 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, 11), + ReadHoldingRegisters(86, 3), + ReadHoldingRegisters(92, 1), + ReadHoldingRegisters(3001, 1), + ReadHoldingRegisters(3004, 2), + ReadHoldingRegisters(3011, 1), + ReadHoldingRegisters(3013, 1), + ReadHoldingRegisters(3015, 2), + ReadHoldingRegisters(3061, 1), + ] + + @property + def writable_ranges(self) -> List[range]: + return super().writable_ranges + [ + range(3001, 3017), + range(3061, 3062), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(91, 2), + ReadHoldingRegisters(96, 1), + ReadHoldingRegisters(98, 2), + 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..282d1f4 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep600.py @@ -0,0 +1,124 @@ +"""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_decimal_field("pv_input_voltage1", 1213, 1) + self.struct.add_decimal_field("pv_input_current1", 1214, 1) + self.struct.add_uint_field("pv_input_power2", 1220) + self.struct.add_decimal_field("pv_input_voltage2", 1221, 1) + self.struct.add_decimal_field("pv_input_current2", 1222, 1) + + # 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_int_field("ac_output_power_phase1", 1510) + self.struct.add_int_field("ac_output_power_phase2", 1517) + self.struct.add_int_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) + + # KM - Consumption + self.struct.add_int_field("consumption_power_phase1", 1430) + self.struct.add_int_field("consumption_power_phase2", 1436) + self.struct.add_int_field("consumption_power_phase3", 1442) + self.struct.add_decimal_field("consumption_voltage_phase1", 1431, 1) + self.struct.add_decimal_field("consumption_voltage_phase2", 1437, 1) + self.struct.add_decimal_field("consumption_voltage_phase3", 1443, 1) + self.struct.add_decimal_field("consumption_current_phase1", 1432, 1) + self.struct.add_decimal_field("consumption_current_phase2", 1438, 1) + self.struct.add_decimal_field("consumption_current_phase3", 1444, 1) + + # KM - Totals + self.struct.add_uint_field('pv_input_power_all', 144) # Total PV in + self.struct.add_uint_field('consumption_power_all', 142) # Total AC out + self.struct.add_uint_field('grid_power_all', 146) # Total Grid in - value only +/- unknown + + # 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 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), + # KM - Consumption + ReadHoldingRegisters(1430, 15), + # KM - Totals + ReadHoldingRegisters(142, 5), + + ] 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/devices/ep800.py b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep800.py new file mode 100644 index 0000000..7ca5dc1 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/devices/ep800.py @@ -0,0 +1,8 @@ +"""EP800 fields.""" + +from ..base_devices.ProtocolV2Device import ProtocolV2Device + + +class EP800(ProtocolV2Device): + def __init__(self, address: str, sn: str): + super().__init__(address, "EP800", 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..db9707e --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/field_attributes.py @@ -0,0 +1,338 @@ +"""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() + + +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"), + + # KM - Consumption + "consumption_power_phase1": PowerFieldAttributes("Consumption Power Phase 1"), + "consumption_power_phase2": PowerFieldAttributes("Consumption Power Phase 2"), + "consumption_power_phase3": PowerFieldAttributes("Consumption Power Phase 3"), + "consumption_voltage_phase1": VoltageFieldAttributes("Consumption Voltage Phase 1"), + "consumption_voltage_phase2": VoltageFieldAttributes("Consumption Voltage Phase 2"), + "consumption_voltage_phase3": VoltageFieldAttributes("Consumption Voltage Phase 3"), + "consumption_current_phase1": CurrentFieldAttributes("Consumption Current Phase 1"), + "consumption_current_phase2": CurrentFieldAttributes("Consumption Current Phase 2"), + "consumption_current_phase3": CurrentFieldAttributes("Consumption Current Phase 3"), + # KM - Consumption end + + # KM - Totals + "pv_input_power_all": PowerFieldAttributes("PV Input Power All"), + "consumption_power_all": PowerFieldAttributes("Consumption Power All"), + "grid_power_all": PowerFieldAttributes("Grid Power All"), + # KM - Totals end + + + # Device specific controls + "power_off": FieldAttributes( + type=FieldType.BOOL, + 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=True, + 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..ed912b2 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/device_builder.py @@ -0,0 +1,58 @@ +"""Device builder functions.""" + +import re + +from ..devices.ac60 import AC60 +from ..devices.ac180 import AC180 +from ..devices.ac180p import AC180P +from ..devices.ac200l import AC200L +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 +from ..devices.ep800 import EP800 + +DEVICE_NAME_RE = re.compile( + r"^(AC60|AC180|AC180P|AC200L|AC200M|AC300|AC500|EB3A|EP500|EP500P|EP600|EP760|EP800)(\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] == "AC180": + return AC180(address, match[2]) + if match[1] == "AC180P": + return AC180P(address, match[2]) + if match[1] == "AC200L": + return AC200L(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]) + if match[1] == "EP800": + return EP800(address, match[2]) + + +def get_type_by_bt_name(bt_name: str): + match = DEVICE_NAME_RE.match(bt_name) + if match is None: + 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..70ef49e --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/struct.py @@ -0,0 +1,208 @@ +"""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]], multiplier: float): + self.range = range + self.multiplier = multiplier + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> int: + val = struct.unpack("!H", data)[0] + + return val * self.multiplier + + 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 IntField(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]], multiplier: float + ): + self.scale = scale + self.range = range + self.multiplier = multiplier + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Decimal: + val = Decimal(struct.unpack("!H", data)[0]) + return (val / 10 ** self.scale) * self.multiplier + + 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, multiplier: float = 1): + self.fields.append(UintField(name, address, range, multiplier)) + + def add_int_field(self, name: str, address: int, range: Tuple[int, int] = None): + self.fields.append(IntField(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, multiplier: float = 1 + ): + self.fields.append(DecimalField(name, address, scale, range, multiplier)) + + 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 61d78c9..545ff4e 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, CONF_TYPE 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, @@ -43,6 +47,23 @@ 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() + + if isinstance(discovery_info.name, str): + name = re.sub("[^A-Z0-9]+", "", discovery_info.name) + discovery_info.manufacturer_data = { + CONF_TYPE: get_type_by_bt_name(name) + } + + # 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.manufacturer_data = { + CONF_TYPE: device_type.strip(), + } + 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() @@ -57,13 +78,14 @@ 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) + return self.async_create_entry( - title=discovery_info.name, + title=name, data={ CONF_ADDRESS: discovery_info.address, - CONF_TYPE: dev_type, - CONF_NAME: re.sub("[^A-Z0-9]+", "", discovery_info.name), + CONF_NAME: name, + CONF_TYPE: discovery_info.manufacturer_data.get(CONF_TYPE, "Unknown"), }, ) diff --git a/custom_components/bluetti_bt/const.py b/custom_components/bluetti_bt/const.py index 75c2406..1568e73 100644 --- a/custom_components/bluetti_bt/const.py +++ b/custom_components/bluetti_bt/const.py @@ -1,7 +1,5 @@ """Constants for the Bluetti BT integration.""" -from bluetti_mqtt.mqtt_client import MqttFieldConfig, MqttFieldType - DOMAIN = "bluetti_bt" MANUFACTURER = "Bluetti" @@ -15,9 +13,24 @@ CONF_MAX_RETRIES = "max_retries" DATA_COORDINATOR = "coordinator" DATA_POLLING_RUNNING = "polling_running" +SUPPORTED_MODELS = [ + "AC60", + "AC200M", + "AC300", + "AC500", + "EB3A", + "EP500", + "EP500P", + "EP600", + "EP760", +] + CONTROL_FIELDS = [ - "ac_output_on", - "dc_output_on", + "ac_output_on_switch", + "dc_output_on_switch", + "power_off", + "eco_on", + "power_lifting_on", ] DIAGNOSTIC_FIELDS = [ @@ -28,509 +41,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 8239c64..de8bd40 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,115 +13,14 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from bluetti_mqtt.bluetooth.client import BluetoothClient -from bluetti_mqtt.bluetooth import ( - BadConnectionError, - ModbusError, - ParseError, - build_device, -) -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 _LOGGER = logging.getLogger(__name__) -class DummyDevice(BluettiDevice): - """Dummy device used to add more fields to existing devices. - - Changes made here are only temporary and should also be - contributed to https://github.com/warhammerkid/bluetti_mqtt - """ - - def __init__(self, device: BluettiDevice): - """Init dummy device with real device.""" - self.struct = device.struct - - if device.type == "EP600": - # 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__(device.address, device.type, device.sn) - self._parent = device - - @property - def pack_num_max(self): - return self._parent.pack_num_max - - @property - def polling_commands(self) -> List[ReadHoldingRegisters]: - if self.type == "EP600": - 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), - ] - - return self._parent.polling_commands - - @property - def pack_polling_commands(self) -> List[ReadHoldingRegisters]: - if self.type == "EP600": - return [] - return self._parent.pack_polling_commands - - @property - def logging_commands(self) -> List[ReadHoldingRegisters]: - return self._parent.logging_commands - - @property - def pack_logging_commands(self) -> List[ReadHoldingRegisters]: - return self._parent.pack_logging_commands - - @property - def writable_ranges(self) -> List[range]: - return self._parent.writable_ranges - - class PollingCoordinator(DataUpdateCoordinator): """Polling coordinator.""" @@ -145,18 +41,8 @@ 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() - bluetti_device = build_device(address, device_name) - self.persistent_conn = persistent_conn - self.polling_timeout = polling_timeout - self.max_retries = max_retries - # Add or modify device fields - self.bluetti_device = DummyDevice(bluetti_device) + self.address = address # Create client self.logger.debug("Creating client") @@ -164,10 +50,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. @@ -175,169 +68,11 @@ 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)) + # Check if device is connected + if bluetooth.async_address_present(self.hass, self.address, connectable=True) is False: + self.logger.warning("Device not connected") + self.last_update_success = False 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) - ) - - 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/manifest.json b/custom_components/bluetti_bt/manifest.json index da4e654..e7030ff 100644 --- a/custom_components/bluetti_bt/manifest.json +++ b/custom_components/bluetti_bt/manifest.json @@ -2,21 +2,26 @@ "domain": "bluetti_bt", "name": "Bluetti BT", "bluetooth": [ + { "local_name": "AC60*" }, + { "local_name": "AC180*" }, + { "local_name": "AC200L*" }, { "local_name": "AC200M*" }, { "local_name": "AC300*" }, { "local_name": "AC500*" }, - { "local_name": "AC60*" }, { "local_name": "EB3A*" }, { "local_name": "EP500*" }, - { "local_name": "EP600*" } + { "local_name": "EP600*" }, + { "local_name": "EP760*" }, + { "local_name": "EP800*" }, + { "local_name": "PBOX*" } ], "codeowners": ["@Patrick762"], "config_flow": true, - "dependencies": ["bluetooth"], + "dependencies": ["bluetooth_adapters"], "documentation": "https://github.com/Patrick762/hassio-bluetti-bt", "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", - "requirements": ["bluetti_mqtt==0.15.0"], - "version": "0.0.11" + "requirements": [], + "version": "0.1.0" } diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 428800e..ba61abe 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -6,33 +6,24 @@ 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.bluetooth import build_device -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 .coordinator import PollingCoordinator, DummyDevice +from .const import DATA_COORDINATOR, DOMAIN, DIAGNOSTIC_FIELDS +from .coordinator import PollingCoordinator from .utils import unique_id_loggable _LOGGER = logging.getLogger(__name__) @@ -54,48 +45,45 @@ async def async_setup_entry( # Add sensors according to device_info bluetti_device = build_device(address, device_name) - bluetti_device = DummyDevice(bluetti_device) sensors_to_add = [] - all_fields = NORMAL_DEVICE_FIELDS - all_fields.update(DC_INPUT_FIELDS) - all_fields.update(ADDITIONAL_DEVICE_FIELDS) + 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) or (len(bluetti_device.pack_polling_commands) > 0 and field_key.startswith("pack_")): 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, ) ) @@ -122,12 +110,13 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): """Init battery entity.""" super().__init__(coordinator) + self._attr_has_entity_name = True e_name = f"{device_info.get('name')} {name}" self._address = address self._response_key = response_key self._attr_device_info = device_info - self._attr_name = e_name + self._attr_name = name self._attr_available = False self._attr_unique_id = get_unique_id(e_name) self._attr_native_unit_of_measurement = unit_of_measurement @@ -136,18 +125,24 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): self._attr_entity_category = category self._options = options + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._attr_available + @callback 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: - _LOGGER.warning( - "Data from coordinator is None. Skipping update for %s", - unique_id_loggable(self._attr_unique_id) + _LOGGER.debug( + "Data from coordinator is None", ) + self._attr_available = False + self.async_write_ha_state() return _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) @@ -156,12 +151,14 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): "Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id) ) self._attr_available = False + self.async_write_ha_state() return response_data = self.coordinator.data.get(self._response_key) if response_data is None: - _LOGGER.warning("No data for available for (%s)", self._response_key) + _LOGGER.debug("No data for available for (%s)", self._response_key) self._attr_available = False + self.async_write_ha_state() return if ( @@ -178,6 +175,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): type(response_data), ) self._attr_available = False + self.async_write_ha_state() return self._attr_available = True @@ -186,10 +184,9 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): if ( self._options is not None and isinstance(response_data, Enum) - and response_data.value < len(self._options) ): # Enum - self._attr_native_value = self._options[response_data.value] + self._attr_native_value = response_data.name else: # Numeric self._attr_native_value = response_data diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py index 62280e4..11efc32 100644 --- a/custom_components/bluetti_bt/switch.py +++ b/custom_components/bluetti_bt/switch.py @@ -23,18 +23,14 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) -from bluetti_mqtt.bluetooth import build_device -from bluetti_mqtt.bluetooth.client import BluetoothClient -from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice -from bluetti_mqtt.mqtt_client import ( - NORMAL_DEVICE_FIELDS, - DC_INPUT_FIELDS, - MqttFieldType, -) +from .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 .coordinator import PollingCoordinator, DummyDevice +from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN +from .coordinator import PollingCoordinator from .utils import mac_loggable, unique_id_loggable _LOGGER = logging.getLogger(__name__) @@ -56,15 +52,12 @@ async def async_setup_entry( # Add sensors according to device_info bluetti_device = build_device(address, device_name) - bluetti_device = DummyDevice(bluetti_device) sensors_to_add = [] - all_fields = NORMAL_DEVICE_FIELDS - all_fields.update(DC_INPUT_FIELDS) - all_fields.update(ADDITIONAL_DEVICE_FIELDS) + 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( @@ -73,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 ) ) @@ -100,38 +93,46 @@ 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 self._entry_id = entry_id self._attr_device_info = device_info - self._attr_name = e_name + self._attr_has_entity_name = True + self._attr_name = name self._attr_available = False self._attr_unique_id = get_unique_id(e_name) self._attr_entity_category = category self._attr_device_class = SwitchDeviceClass.OUTLET + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._attr_available + @callback 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)) if not isinstance(self.coordinator.data, dict): - _LOGGER.error( + _LOGGER.debug( "Invalid data from coordinator (switch.%s)", unique_id_loggable(self._attr_unique_id) ) self._attr_available = False + self.async_write_ha_state() return response_data = self.coordinator.data.get(self._response_key) if response_data is None: self._attr_available = False + self.async_write_ha_state() return if not isinstance(response_data, bool): @@ -141,6 +142,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): response_data, ) self._attr_available = False + self.async_write_ha_state() return self._attr_available = True @@ -170,7 +172,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 @@ -184,7 +186,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/translations/de.json b/custom_components/bluetti_bt/translations/de.json index 7093834..9451cab 100644 --- a/custom_components/bluetti_bt/translations/de.json +++ b/custom_components/bluetti_bt/translations/de.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "data": { + "address": "Gerät", + "type": "Modell (erforderlich bei PBOX Geräten)" + } + } + } + }, "options": { "step": { "init": { diff --git a/custom_components/bluetti_bt/translations/en.json b/custom_components/bluetti_bt/translations/en.json index 1229863..c5b4610 100644 --- a/custom_components/bluetti_bt/translations/en.json +++ b/custom_components/bluetti_bt/translations/en.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "data": { + "address": "Device", + "type": "Model (required for PBOX devices)" + } + } + } + }, "options": { "step": { "init": { diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..90b73db --- /dev/null +++ b/test.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +# Execute unittests of python library +python3 -m unittest discover -s tests -p "*_test.py" diff --git a/tests/device_builder_test.py b/tests/device_builder_test.py new file mode 100644 index 0000000..705596d --- /dev/null +++ b/tests/device_builder_test.py @@ -0,0 +1,124 @@ +"""Unittest for device builder.""" + +import unittest + +from custom_components.bluetti_bt.bluetti_bt_lib.utils.device_builder import build_device +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac60 import AC60 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac180 import AC180 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac180p import AC180P +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac200m import AC200M +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac300 import AC300 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac500 import AC500 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.eb3a import EB3A +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep500 import EP500 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep500p import EP500P +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep600 import EP600 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep760 import EP760 +from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep800 import EP800 + + +class TestDeviceBuilder(unittest.TestCase): + def test_build_Unknow(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "PBOX56786746478" + with self.assertRaises(TypeError): + build_device(bt_addr, bt_name) + + def test_build_ac60(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "AC6056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, AC60) + self.assertEqual(built.address, bt_addr) + + def test_build_ac180(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "AC18056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, AC180) + self.assertEqual(built.address, bt_addr) + + def test_build_ac180p(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "AC180P56786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, AC180P) + self.assertEqual(built.address, bt_addr) + + def test_build_ac200m(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "AC200M56786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, AC200M) + self.assertEqual(built.address, bt_addr) + + def test_build_ac300(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "AC30056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, AC300) + self.assertEqual(built.address, bt_addr) + + def test_build_ac500(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "AC50056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, AC500) + self.assertEqual(built.address, bt_addr) + + def test_build_eb3a(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "EB3A56786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, EB3A) + self.assertEqual(built.address, bt_addr) + + def test_build_ep500(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "EP50056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, EP500) + self.assertEqual(built.address, bt_addr) + + def test_build_ep500p(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "EP500P56786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, EP500P) + self.assertEqual(built.address, bt_addr) + + def test_build_EP600(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "EP60056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, EP600) + self.assertEqual(built.address, bt_addr) + + def test_build_EP760(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "EP76056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, EP760) + self.assertEqual(built.address, bt_addr) + + def test_build_EP800(self): + bt_addr = "aa:bb:cc:dd:ee:ff" + bt_name = "EP80056786746478" + built = build_device(bt_addr, bt_name) + + self.assertIsInstance(built, EP800) + self.assertEqual(built.address, bt_addr) + +if __name__ == '__main__': + unittest.main()