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..8ce7432 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/base_devices/BluettiDevice.py @@ -0,0 +1,63 @@ +"""Bluetti device.""" + +# Reduced copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/bluetti_device.py + +from typing import Any, List + +from ..utils.commands import ReadHoldingRegisters, WriteSingleRegister +from ..utils.struct import BoolField, DeviceStruct, EnumField + + +class BluettiDevice: + struct: DeviceStruct + + def __init__(self, address: str, type: str, sn: str): + self.address = address + self.type = type + self.sn = sn + + def parse(self, address: int, data: bytes) -> dict: + return self.struct.parse(address, data) + + @property + def pack_num_max(self): + """ + A given device has a maximum number of battery packs, including the + internal battery if it has one. We can provide this information statically + so it's not necessary to poll the device. + """ + return 1 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + """A given device has an optimal set of commands for polling""" + raise NotImplementedError + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + """A given device may have a set of commands for polling pack data""" + return [] + + @property + def writable_ranges(self) -> List[range]: + """The address ranges that are writable""" + return [] + + def has_field(self, field: str): + return any(f.name == field for f in self.struct.fields) + + def has_field_setter(self, field: str): + matches = [f for f in self.struct.fields if f.name == field] + return any(any(f.address in r for r in self.writable_ranges) for f in matches) + + def build_setter_command(self, field: str, value: Any): + matches = [f for f in self.struct.fields if f.name == field] + device_field = next(f for f in matches if any(f.address in r for r in self.writable_ranges)) + + # Convert value to an integer + if isinstance(device_field, EnumField): + value = device_field.enum[value].value + elif isinstance(device_field, BoolField): + value = 1 if value else 0 + + return WriteSingleRegister(device_field.address, value) diff --git a/custom_components/bluetti_bt/bluetti_bt_lib/const.py b/custom_components/bluetti_bt/bluetti_bt_lib/const.py new file mode 100644 index 0000000..7bc8f82 --- /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/exceptions.py b/custom_components/bluetti_bt/bluetti_bt_lib/exceptions.py new file mode 100644 index 0000000..6e708b9 --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/exceptions.py @@ -0,0 +1,14 @@ +"""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/utils/commands.py b/custom_components/bluetti_bt/bluetti_bt_lib/utils/commands.py new file mode 100644 index 0000000..87cad7b --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/commands.py @@ -0,0 +1,111 @@ +"""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/struct.py b/custom_components/bluetti_bt/bluetti_bt_lib/utils/struct.py new file mode 100644 index 0000000..9e9f27b --- /dev/null +++ b/custom_components/bluetti_bt/bluetti_bt_lib/utils/struct.py @@ -0,0 +1,179 @@ +"""Struct definitions.""" + +# Copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/struct.py + +from decimal import Decimal +from enum import Enum +import struct +from typing import Any, List, Optional, Tuple, Type + + +def swap_bytes(data: bytes): + """Swaps the place of every other byte, returning a new byte array""" + arr = bytearray(data) + for i in range(0, len(arr) - 1, 2): + arr[i], arr[i + 1] = arr[i + 1], arr[i] + return arr + + +class DeviceField: + def __init__(self, name: str, address: int, size: int): + self.name = name + self.address = address + self.size = size + + def parse(self, data: bytes) -> Any: + raise NotImplementedError + + def in_range(self, val: Any) -> bool: + return True + + +class UintField(DeviceField): + def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]]): + self.range = range + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> int: + return struct.unpack('!H', data)[0] + + def in_range(self, val: int) -> bool: + if self.range is None: + return True + else: + return val >= self.range[0] and val <= self.range[1] + + +class BoolField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> bool: + return struct.unpack('!H', data)[0] == 1 + + +class EnumField(DeviceField): + def __init__(self, name: str, address: int, enum: Type[Enum]): + self.enum = enum + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Any: + val = struct.unpack('!H', data)[0] + return self.enum(val) + + +class DecimalField(DeviceField): + def __init__(self, name: str, address: int, scale: int, range: Optional[Tuple[int, int]]): + self.scale = scale + self.range = range + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Decimal: + val = Decimal(struct.unpack('!H', data)[0]) + return val / 10 ** self.scale + + def in_range(self, val: Decimal) -> bool: + if self.range is None: + return True + else: + return val >= self.range[0] and val <= self.range[1] + + +class DecimalArrayField(DeviceField): + def __init__(self, name: str, address: int, size: int, scale: int): + self.scale = scale + super().__init__(name, address, size) + + def parse(self, data: bytes) -> Decimal: + values = list(struct.unpack(f'!{self.size}H', data)) + return [Decimal(v) / 10 ** self.scale for v in values] + + +class StringField(DeviceField): + """Fixed-width null-terminated string field""" + def parse(self, data: bytes) -> str: + return data.rstrip(b'\0').decode('ascii') + + +class SwapStringField(DeviceField): + """Fixed-width null-terminated string field""" + def parse(self, data: bytes) -> str: + return swap_bytes(data).rstrip(b'\0').decode('ascii') + + +class VersionField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 2) + + def parse(self, data: bytes) -> int: + values = struct.unpack('!2H', data) + return Decimal(values[0] + (values[1] << 16)) / 100 + + +class SerialNumberField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 4) + + def parse(self, data: bytes) -> int: + values = struct.unpack('!4H', data) + return values[0] + (values[1] << 16) + (values[2] << 32) + (values[3] << 48) + + +class DeviceStruct: + fields: List[DeviceField] + + def __init__(self): + self.fields = [] + + def add_uint_field(self, name: str, address: int, range: Tuple[int, int] = None): + self.fields.append(UintField(name, address, range)) + + def add_bool_field(self, name: str, address: int): + self.fields.append(BoolField(name, address)) + + def add_enum_field(self, name: str, address: int, enum: Type[Enum]): + self.fields.append(EnumField(name, address, enum)) + + def add_decimal_field(self, name: str, address: int, scale: int, range: Tuple[int, int] = None): + self.fields.append(DecimalField(name, address, scale, range)) + + def add_decimal_array_field(self, name: str, address: int, size: int, scale: int): + self.fields.append(DecimalArrayField(name, address, size, scale)) + + def add_string_field(self, name: str, address: int, size: int): + self.fields.append(StringField(name, address, size)) + + def add_swap_string_field(self, name: str, address: int, size: int): + self.fields.append(SwapStringField(name, address, size)) + + def add_version_field(self, name: str, address: int): + self.fields.append(VersionField(name, address)) + + def add_sn_field(self, name: str, address: int): + self.fields.append(SerialNumberField(name, address)) + + def parse(self, starting_address: int, data: bytes) -> dict: + # Offsets and size are counted in 2 byte chunks, so for the range we + # need to divide the byte size by 2 + data_size = int(len(data) / 2) + + # Filter out fields not in range + r = range(starting_address, starting_address + data_size) + fields = [f for f in self.fields + if f.address in r and f.address + f.size - 1 in r] + + # Parse fields + parsed = {} + for f in fields: + data_start = 2 * (f.address - starting_address) + field_data = data[data_start:data_start + 2 * f.size] + val = f.parse(field_data) + + # Skip if the value is "out-of-range" - sometimes the sensors + # report weird values + if not f.in_range(val): + continue + + parsed[f.name] = val + + return parsed diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 03b1a2b..40329d5 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -1,7 +1,7 @@ """Coordinator for Bluetti integration.""" from __future__ import annotations -from typing import List, cast +from typing import cast import asyncio from datetime import timedelta @@ -16,14 +16,9 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from bluetti_mqtt.bluetooth.client import BluetoothClient -from bluetti_mqtt.bluetooth import ( - BadConnectionError, - ModbusError, - ParseError, -) -from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice -from bluetti_mqtt.core.commands import ReadHoldingRegisters +from .bluetti_bt_lib.const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID +from .bluetti_bt_lib.exceptions import BadConnectionError, ModbusError, ParseError +from .bluetti_bt_lib.utils.commands import ReadHoldingRegisters from .const import DATA_POLLING_RUNNING, DOMAIN from .utils import mac_loggable, build_device @@ -108,7 +103,7 @@ class PollingCoordinator(DataUpdateCoordinator): # Attach notifier if needed if not self.has_notifier: await self.client.start_notify( - BluetoothClient.NOTIFY_UUID, self._notification_handler + NOTIFY_UUID, self._notification_handler ) self.has_notifier = True @@ -171,7 +166,7 @@ class PollingCoordinator(DataUpdateCoordinator): # Disconnect if connection not persistant if not self.persistent_conn: if self.has_notifier: - await self.client.stop_notify(BluetoothClient.NOTIFY_UUID) + await self.client.stop_notify(NOTIFY_UUID) self.has_notifier = False await self.client.disconnect() @@ -191,12 +186,12 @@ class PollingCoordinator(DataUpdateCoordinator): # Make request self.logger.debug("Requesting %s", command) await self.client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) + WRITE_UUID, bytes(command) ) # Wait for response res = await asyncio.wait_for( - self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT + self.notify_future, timeout=RESPONSE_TIMEOUT ) # Process data