Compare commits

...
46 Commits
Author SHA1 Message Date
Patrick762 85e50498f6 Merge pull request #64 from Patrick762/ep800
EP800
2024-02-25 15:40:30 +01:00
Patrick762 5fa1c8631f next beta release 2024-02-25 15:37:48 +01:00
Patrick762 9cba466228 basic ep800 support 2024-02-25 15:37:12 +01:00
Patrick762 cc42617bdb Merge pull request #63 from Patrick762/values-fix
Values fix
2024-02-25 15:08:50 +01:00
Patrick762 4d27f108fa fixed negative ac and dc solar values 2024-02-25 15:04:18 +01:00
Patrick762 6412630c85 added new int field 2024-02-25 15:03:27 +01:00
Patrick762 3f51dd829e prepare beta version 0.0.17 2024-02-18 17:02:24 +01:00
Patrick762 64fbcbd08b Merge pull request #52 from Patrick762/integrated-lib
Switching to integrated bluetti library
2024-02-18 16:55:34 +01:00
Patrick762 c59881de7f fix switch coordinator references 2024-02-18 16:52:23 +01:00
Patrick762 842f133cd4 code cleanup 2024-02-18 16:49:27 +01:00
Patrick762 a50deaae37 added device type detection to config_flow 2024-02-18 16:37:37 +01:00
Patrick762 9b7fd82591 extracted DeviceReader class from coordinator 2024-02-18 16:32:05 +01:00
Patrick762 e40e056e56 added more diagnostic fields 2024-02-18 14:46:52 +01:00
Patrick762 38e1fa4ecd added ReadHoldingRegisters for ep600 2024-02-18 14:44:44 +01:00
Patrick762 564a971d7c move get_type_by_bt_name into lib 2024-02-18 14:35:16 +01:00
Patrick762 caac9330b4 field_attributes cleanup 2024-02-18 14:26:55 +01:00
Patrick762 d8999c6470 fixed field_attributes dataclasses 2024-02-18 14:21:47 +01:00
Patrick762 c45994a9a6 replaced bluetti_mqtt imports with local lib 2024-02-18 13:54:40 +01:00
Patrick762 18825f9062 removed old ep760 class 2024-02-18 13:36:54 +01:00
Patrick762 8409221675 added ep760 2024-02-18 13:35:17 +01:00
Patrick762 2abcbbb392 added build_device() 2024-02-18 13:29:20 +01:00
Patrick762 71bf9b1952 removed bluetti_mqtt requirement 2024-02-18 13:19:02 +01:00
Patrick762 d8c61e1ebe added ep600 2024-02-17 21:23:08 +01:00
Patrick762 03b11a69d5 added ep500p 2024-02-17 20:46:20 +01:00
Patrick762 3a709f9bc5 added ep500 2024-02-17 20:43:34 +01:00
Patrick762 e395c400b5 added eb3a 2024-02-17 20:36:32 +01:00
Patrick762 3d65d8b00f added ac500 2024-02-17 20:24:44 +01:00
Patrick762 a183e8b02f make options optional 2024-02-17 20:19:19 +01:00
Patrick762 432d46d0f1 black code formatting 2024-02-17 20:14:12 +01:00
Patrick762 928ff2bf5d added ac300 2024-02-17 20:09:59 +01:00
Patrick762 d9d4860d44 added ac200m 2024-02-17 18:22:41 +01:00
Patrick762 5fcd57b517 added enums 2024-02-17 18:08:05 +01:00
Patrick762 4f92e9147e added pack polling registers to base devices 2024-02-17 18:07:51 +01:00
Patrick762 037ce83891 added pack selector 2024-02-17 18:00:59 +01:00
Patrick762 f7b4d8dc37 added ac60 2024-02-17 17:53:34 +01:00
Patrick762 01cb7f35a5 added pack fields to base devices 2024-02-17 17:52:08 +01:00
Patrick762 cac64b40bd added power_generation to base devices 2024-02-17 17:25:57 +01:00
Patrick762 4231b34bb8 added basic v1 and v2 protocol devices 2024-02-16 21:30:29 +01:00
Patrick762 f6f19b27e1 added local bluetti lib 2024-02-16 21:07:06 +01:00
Patrick762 0943ecf187 fix manifest version number 2024-02-10 14:20:37 +01:00
Patrick762 58f2ec50c4 added sleep() between pack pollings 2024-02-10 14:15:52 +01:00
Patrick762 ad536235bb removed device name from entity name 2024-02-10 13:59:01 +01:00
Patrick762 ba6611b084 removed duplicate re.sub() 2024-02-10 13:21:49 +01:00
Patrick762 3e4b8601e8 added dropdown to select device model 2024-02-10 13:19:16 +01:00
Patrick762 ebbcea3c4c fixed circular import 2024-02-05 19:10:11 +01:00
Patrick762 9ef9d38a6e renamed ep700 to ep760 2024-02-04 12:05:53 +01:00
36 changed files with 1851 additions and 997 deletions

No files matched your search

+2 -7
View File
@@ -5,8 +5,6 @@
Bluetti Integration for Home Assistant
Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt).
## Disclaimer
This integration is provided without any warranty or support by Bluetti (unfortunately). I do not take responsibility for any problems it may cause in all cases. Use it at your own risk.
@@ -17,7 +15,6 @@ After the installation, you can use this button to install the integration:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Patrick762&repository=hassio-bluetti-bt&category=integration)
### Supported devices:
(based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices))
- AC60
- AC200M
@@ -27,10 +24,8 @@ After the installation, you can use this button to install the integration:
- EP500
- EP500P
- EP600 (tested)
- EP700 (beta, untested)
### Available sensors:
All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)).
- 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):
+2 -37
View File
@@ -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
@@ -12,8 +13,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.exceptions import ConfigEntryNotReady
from bluetti_mqtt.bluetooth import build_device as orig_build_device
from .const import (
CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN,
@@ -22,15 +21,12 @@ from .const import (
CONF_USE_CONTROLS,
DATA_COORDINATOR,
DATA_POLLING_RUNNING,
DEVICE_NAME_RE,
DOMAIN,
MANUFACTURER,
)
from .coordinator import PollingCoordinator
from .devices.ep600 import EP600
from .devices.ep700 import EP700
PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
PLATFORMS: List[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
@@ -88,34 +84,3 @@ def get_unique_id(name: str, sensor_type: str | None = None):
if sensor_type is not None:
return f"{sensor_type}.{res}"
return res
def get_type_by_bt_name(bt_name: str):
"""Get the device type."""
dev_type = "Unknown"
if bt_name.startswith("AC200M"):
dev_type = "AC200M"
elif bt_name.startswith("AC300"):
dev_type = "AC300"
elif bt_name.startswith("AC500"):
dev_type = "AC500"
elif bt_name.startswith("AC60"):
dev_type = "AC60"
elif bt_name.startswith("EB3A"):
dev_type = "EB3A"
elif bt_name.startswith("EP500"):
dev_type = "EP500"
elif bt_name.startswith("EP600"):
dev_type = "EP600"
elif bt_name.startswith("EP700"):
dev_type = "EP700"
return dev_type
def build_device(address: str, name: str):
match = DEVICE_NAME_RE.match(name)
if match[1] == 'EP700':
return EP700(address, match[2])
elif match[1] == 'EP600':
return EP600(address, match[2])
else:
return orig_build_device(address, name)
+9 -14
View File
@@ -18,14 +18,11 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
)
from bluetti_mqtt.mqtt_client import (
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, FieldType
from .bluetti_bt_lib.utils.device_builder import build_device
from . import device_info as dev_info, get_unique_id, build_device
from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS
from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN
from .coordinator import PollingCoordinator
from .utils import unique_id_loggable
@@ -50,12 +47,10 @@ async def async_setup_entry(
bluetti_device = build_device(address, device_name)
sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
all_fields = FIELD_ATTRIBUTES
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key):
if field_config.type == MqttFieldType.BOOL:
if field_config.type == FieldType.BOOL:
category = None
if field_config.setter is True:
category = EntityCategory.DIAGNOSTIC
@@ -66,7 +61,7 @@ async def async_setup_entry(
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
field_config.name,
category=category,
)
)
@@ -95,7 +90,7 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
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
@@ -104,7 +99,7 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.persistent_conn and not self.coordinator.client.is_connected:
if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
@@ -0,0 +1,3 @@
from .const import *
from .exceptions import *
from .utils.commands import ReadHoldingRegisters
@@ -0,0 +1,65 @@
"""Bluetti device."""
# Reduced copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/bluetti_device.py
from typing import Any, List
from ..utils.commands import ReadHoldingRegisters, WriteSingleRegister
from ..utils.struct import BoolField, DeviceStruct, EnumField
class BluettiDevice:
struct: DeviceStruct
def __init__(self, address: str, type: str, sn: str):
self.address = address
self.type = type
self.sn = sn
def parse(self, address: int, data: bytes) -> dict:
return self.struct.parse(address, data)
@property
def pack_num_max(self):
"""
A given device has a maximum number of battery packs, including the
internal battery if it has one. We can provide this information statically
so it's not necessary to poll the device.
"""
return 1
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device has an optimal set of commands for polling"""
raise NotImplementedError
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device may have a set of commands for polling pack data"""
return []
@property
def writable_ranges(self) -> List[range]:
"""The address ranges that are writable"""
return []
def has_field(self, field: str):
return any(f.name == field for f in self.struct.fields)
def has_field_setter(self, field: str):
matches = [f for f in self.struct.fields if f.name == field]
return any(any(f.address in r for r in self.writable_ranges) for f in matches)
def build_setter_command(self, field: str, value: Any):
matches = [f for f in self.struct.fields if f.name == field]
device_field = next(
f for f in matches if any(f.address in r for r in self.writable_ranges)
)
# Convert value to an integer
if isinstance(device_field, EnumField):
value = device_field.enum[value].value
elif isinstance(device_field, BoolField):
value = 1 if value else 0
return WriteSingleRegister(device_field.address, value)
@@ -0,0 +1,75 @@
"""Base device definition for V1 Protocol devices."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..utils.struct import DeviceStruct
from .BluettiDevice import BluettiDevice
class ProtocolV1Device(BluettiDevice):
def __init__(self, address: str, type: str, sn: str):
self.struct = DeviceStruct()
# Device info
self.struct.add_string_field("device_type", 10, 6)
self.struct.add_sn_field("serial_number", 17)
# Power IO
self.struct.add_uint_field("dc_input_power", 36)
self.struct.add_uint_field("ac_input_power", 37)
self.struct.add_uint_field("ac_output_power", 38)
self.struct.add_uint_field("dc_output_power", 39)
# Power IO statistics
self.struct.add_decimal_field(
"power_generation", 41, 1
) # Total power generated since last reset (kwh)
# Battery
self.struct.add_uint_field("total_battery_percent", 43)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
# Output state
self.struct.add_bool_field("ac_output_on", 48)
self.struct.add_bool_field("dc_output_on", 49)
# Pack selector
self.struct.add_uint_field("pack_num", 3006) # internal
# Output controls
self.struct.add_bool_field("ac_output_on_switch", 3007)
self.struct.add_bool_field("dc_output_on_switch", 3008)
super().__init__(address, type, sn)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(10, 10),
ReadHoldingRegisters(36, 4),
ReadHoldingRegisters(41, 1),
ReadHoldingRegisters(43, 1),
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(48, 2),
]
@property
def writable_ranges(self) -> List[range]:
return [range(3006, 3008)]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
]
@@ -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 []
@@ -0,0 +1,219 @@
"""Device reader."""
import asyncio
import logging
from typing import Any, Callable, List, cast
import async_timeout
from bleak import BleakClient, BleakError
from ..base_devices.BluettiDevice import BluettiDevice
from ..const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID
from ..exceptions import BadConnectionError, ModbusError, ParseError
from ..utils.commands import ReadHoldingRegisters
_LOGGER = logging.getLogger(__name__)
class DeviceReader:
def __init__(
self,
bleak_client: BleakClient,
bluetti_device: BluettiDevice,
future_builder_method: Callable[[], asyncio.Future[Any]],
persistent_conn: bool = False,
polling_timeout: int = 45,
max_retries: int = 5,
) -> None:
self.client = bleak_client
self.bluetti_device = bluetti_device
self.create_future = future_builder_method
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
self.has_notifier = False
self.notify_future: asyncio.Future[Any] | None = None
self.current_command = None
self.notify_response = bytearray()
# polling mutex to guard against switches
self.polling_lock = asyncio.Lock()
async def read_data(
self, filter_registers: List[ReadHoldingRegisters] | None = None
) -> dict | None:
_LOGGER.info("Reading data")
if self.bluetti_device is None:
_LOGGER.error("Device is None")
return None
polling_commands = self.bluetti_device.polling_commands
pack_commands = self.bluetti_device.pack_polling_commands
if filter_registers is not None:
polling_commands = filter_registers
pack_commands = []
parsed_data: dict = {}
async with self.polling_lock:
try:
async with async_timeout.timeout(self.polling_timeout):
# Reconnect if not connected
for attempt in range(1, self.max_retries + 1):
try:
if not self.client.is_connected:
await self.client.connect()
break
except Exception as e:
if attempt == self.max_retries or attempt == 1:
raise # pass exception on max_retries attempt
else:
_LOGGER.warning(
f"Connect unsucessful (attempt {attempt}): {e}. Retrying..."
)
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
# Execute polling commands
for command in polling_commands:
try:
body = command.parse_response(
await self._async_send_command(command)
)
_LOGGER.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
_LOGGER.warning("Got a parse exception")
# Execute pack polling commands
if len(pack_commands) > 0:
_LOGGER.debug("Polling battery packs")
for pack in range(1, self.bluetti_device.pack_num_max + 1):
# Set current pack number
await self._async_send_command(
self.bluetti_device.build_setter_command(
"pack_num", pack
)
)
# We need to wait after switching packs for the data to be available
await asyncio.sleep(5)
for command in pack_commands:
# Request & parse result for each pack
try:
body = command.parse_response(
await self._async_send_command(command)
)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
pack_number = parsed.get("pack_num")
if (
not isinstance(pack_number, int)
or pack_number != pack
):
_LOGGER.debug(
"Parsed pack_num(%s) does not match expected '%s'",
pack_number,
pack,
)
continue
for key, value in parsed.items():
parsed_data.update({key + str(pack): value})
except ParseError:
_LOGGER.warning("Got a parse exception...")
except TimeoutError:
_LOGGER.error("Polling timed out")
return None
except BleakError as err:
_LOGGER.error("Bleak error: %s", err)
return None
finally:
# Disconnect if connection not persistant
if not self.persistent_conn:
if self.has_notifier:
await self.client.stop_notify(NOTIFY_UUID)
self.has_notifier = False
await self.client.disconnect()
return parsed_data
async def _async_send_command(self, command: ReadHoldingRegisters) -> bytes:
"""Send command and return response"""
try:
# Prepare to make request
self.current_command = command
self.notify_future = self.create_future()
self.notify_response = bytearray()
# Make request
_LOGGER.debug("Requesting %s", command)
await self.client.write_gatt_char(WRITE_UUID, bytes(command))
# Wait for response
res = await asyncio.wait_for(self.notify_future, timeout=RESPONSE_TIMEOUT)
# Process data
_LOGGER.debug("Got %s bytes", len(res))
return cast(bytes, res)
except TimeoutError:
_LOGGER.warning("Polling single command timed out")
except ModbusError as err:
_LOGGER.warning(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
_LOGGER.warning(
"Needed to disconnect due to error: %s (This can also be the case if you used device controls)",
err,
)
# caught an exception, return empty bytes object
return bytes()
def _notification_handler(self, _sender: int, data: bytearray):
"""Handle bt data."""
# Ignore notifications we don't expect
if self.notify_future is None or self.notify_future.done():
_LOGGER.warning("Unexpected notification")
return
# If something went wrong, we might get weird data.
if data == b"AT+NAME?\r" or data == b"AT+ADV?\r":
err = BadConnectionError("Got AT+ notification")
self.notify_future.set_exception(err)
return
# Save data
self.notify_response.extend(data)
if len(self.notify_response) == self.current_command.response_size():
if self.current_command.is_valid_response(self.notify_response):
self.notify_future.set_result(self.notify_response)
else:
self.notify_future.set_exception(ParseError("Failed checksum"))
elif self.current_command.is_exception_response(self.notify_response):
# We got a MODBUS command exception
msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}"
self.notify_future.set_exception(ModbusError(msg))
@@ -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
@@ -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"
@@ -0,0 +1,48 @@
"""AC200M fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC200M(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC200M", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_uint_field("internal_ac_voltage", 71)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 1)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls
self.struct.add_bool_field("power_off", 3060)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 3
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 4),
ReadHoldingRegisters(86, 3),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [range(3060, 3061)]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return super().pack_polling_commands + [ReadHoldingRegisters(105, 16)]
@@ -0,0 +1,69 @@
"""AC300 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC300(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC300", sn)
# Details (matches EP500 + AC500)
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_uint_field("internal_ac_voltage", 71)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 1)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls (matches EP500 + AC500)
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 4
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 10),
ReadHoldingRegisters(86, 3),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3016),
3061,
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return super().pack_polling_commands + [
ReadHoldingRegisters(105, 16),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,67 @@
"""AC500 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC500(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC500", sn)
# Details (matches EP500 + AC300)
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_uint_field("internal_ac_voltage", 71)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 1)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs (matches EP500)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls (matches EP500 + AC300)
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 4
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 10),
ReadHoldingRegisters(86, 3),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3016),
3061,
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return super().pack_polling_commands + [
ReadHoldingRegisters(105, 16),
]
@@ -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)
@@ -0,0 +1,39 @@
"""EB3A fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import ChargingMode, EcoShutdown, LedMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EB3A(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EB3A", sn)
# Details
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
# Controls
self.struct.add_enum_field("led_mode", 3034, LedMode)
self.struct.add_bool_field("power_off", 3060)
self.struct.add_bool_field("eco_on", 3063)
self.struct.add_enum_field("eco_shutdown", 3064, EcoShutdown)
self.struct.add_enum_field("charging_mode", 3065, ChargingMode)
self.struct.add_bool_field("power_lifting_on", 3066)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(77, 1),
ReadHoldingRegisters(86, 1),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
3034,
3060,
range(3063, 3066),
]
@@ -0,0 +1,63 @@
"""EP500 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EP500(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP500", sn)
# Details (matches AC500 + AC300)
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_uint_field("internal_ac_voltage", 71)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 1)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs (matches AC500)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls (matches AC500 + AC300)
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 10),
ReadHoldingRegisters(86, 3),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3016),
3061,
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return super().pack_polling_commands + [
ReadHoldingRegisters(105, 16),
]
@@ -0,0 +1,63 @@
"""EP500P fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EP500P(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP500P", sn)
# Details (matches AC500 + AC300)
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_uint_field("internal_ac_voltage", 71)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 1)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs (matches AC500)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls (matches AC500 + AC300)
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 10),
ReadHoldingRegisters(86, 3),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3016),
3061,
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return super().pack_polling_commands + [
ReadHoldingRegisters(105, 16),
]
@@ -0,0 +1,114 @@
"""EP600 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class EP600(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP600", sn)
# Details
self.struct.add_uint_field("battery_range_start", 2022)
self.struct.add_uint_field("battery_range_end", 2023)
self.struct.add_uint_field("max_ac_input_power", 2213)
self.struct.add_uint_field("max_ac_input_current", 2214)
self.struct.add_uint_field("max_ac_output_power", 2215)
self.struct.add_uint_field("max_ac_output_current", 2216)
# DC Solar
self.struct.add_uint_field("pv_input_power1", 1212)
self.struct.add_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)
# Statistics
self.struct.add_decimal_field(
"total_ac_consumption", 152, 1
) # Load consumption
self.struct.add_decimal_field(
"total_grid_consumption", 156, 1
) # Grid consumption stats
self.struct.add_decimal_field("total_grid_feed", 158, 1)
# Battery packs
self.struct.add_swap_string_field("battery_type", 6101, 6) # internal
self.struct.add_sn_field("battery_serial_number", 6107) # internal
self.struct.add_version_field("bcu_version", 6175) # internal
self.struct.add_version_field("bmu_version", 6178) # internal
self.struct.add_version_field("safety_module_version", 6181) # internal
self.struct.add_version_field("high_voltage_module_version", 6184) # internal
# @property
# def pack_num_max(self):
# return 16
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(2022, 2),
ReadHoldingRegisters(2213, 5),
ReadHoldingRegisters(1212, 3),
ReadHoldingRegisters(1220, 3),
ReadHoldingRegisters(1228, 3),
ReadHoldingRegisters(1236, 3),
ReadHoldingRegisters(1244, 3),
ReadHoldingRegisters(1300, 1),
ReadHoldingRegisters(1313, 3),
ReadHoldingRegisters(1319, 3),
ReadHoldingRegisters(1325, 3),
ReadHoldingRegisters(1500, 1),
ReadHoldingRegisters(1510, 3),
ReadHoldingRegisters(1517, 3),
ReadHoldingRegisters(1524, 3),
ReadHoldingRegisters(152, 1),
ReadHoldingRegisters(156, 1),
ReadHoldingRegisters(158, 1),
]
# @property
# def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
# return super().pack_polling_commands + [
# ReadHoldingRegisters(6101, 10),
# ReadHoldingRegisters(6175, 12),
# ]
@@ -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)
@@ -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)
@@ -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
@@ -0,0 +1,319 @@
"""Field attributes for device fields."""
from enum import Enum, auto, unique
from .field_enums import (
AutoSleepMode,
ChargingMode,
EcoShutdown,
LedMode,
OutputMode,
SplitPhaseMachineType,
UpsMode,
)
@unique
class FieldType(Enum):
NUMERIC = auto()
BOOL = auto()
ENUM = auto()
BUTTON = auto()
class FieldAttributes:
def __init__(
self,
type: FieldType = FieldType.NUMERIC,
setter: bool = False,
name: str = "",
unit_of_measurement: str | None = None,
device_class: str | None = None,
state_class: str | None = None,
options: Enum | None = None,
):
self.type = type
self.setter = setter
self.name = name
self.unit_of_measurement = unit_of_measurement
self.device_class = device_class
self.state_class = state_class
self.options = options
class PowerFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="W",
device_class="power",
state_class="measurement",
)
class VoltageFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="V",
device_class="voltage",
state_class="measurement",
)
class CurrentFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="A",
device_class="current",
state_class="measurement",
)
class EnergyFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="kWh",
device_class="energy",
state_class="total_increasing",
)
class FrequencyFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="Hz",
device_class="frequency",
state_class="measurement",
)
class OutletFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
setter: bool = False,
):
super().__init__(
type=FieldType.BOOL,
name=name,
setter=setter,
device_class="outlet",
)
FIELD_ATTRIBUTES: dict[str, FieldAttributes] = {
# Base device fields
"dc_input_power": PowerFieldAttributes("DC Input Power"),
"ac_input_power": PowerFieldAttributes("AC Input Power"),
"ac_output_power": PowerFieldAttributes("AC Output Power"),
"dc_output_power": PowerFieldAttributes("DC Output Power"),
"power_generation": EnergyFieldAttributes("Total Power Generation"),
"total_battery_percent": FieldAttributes(
type=FieldType.NUMERIC,
name="Total Battery Percent",
unit_of_measurement="%",
device_class="battery",
state_class="measurement",
),
"total_battery_voltage": VoltageFieldAttributes("Total Battery Voltage"),
"ac_output_on": OutletFieldAttributes("AC Output"),
"dc_output_on": OutletFieldAttributes("DC Output"),
# Controls
"ac_output_on_switch": OutletFieldAttributes(
name="AC Output",
setter=True,
),
"dc_output_on_switch": OutletFieldAttributes(
name="DC Output",
setter=True,
),
# Device specific fields
"ac_output_mode": FieldAttributes(
type=FieldType.ENUM,
name="AC Output Mode",
options=OutputMode,
),
"internal_ac_voltage": VoltageFieldAttributes("Internal AC Voltage"),
"internal_current_one": CurrentFieldAttributes("Internal Current Sensor 1"),
"internal_power_one": PowerFieldAttributes("Internal Power Sensor 1"),
"internal_ac_frequency": FrequencyFieldAttributes("Internal AC Frequency"),
"internal_current_two": CurrentFieldAttributes("Internal Current Sensor 2"),
"internal_power_two": PowerFieldAttributes("Internal Power Sensor 2"),
"ac_input_voltage": VoltageFieldAttributes("AC Input Voltage"),
"internal_current_three": CurrentFieldAttributes("Internal Current Sensor 3"),
"internal_power_three": PowerFieldAttributes("Internal Power Sensor 3"),
"ac_input_frequency": FrequencyFieldAttributes("AC Input Frequency"),
"internal_dc_input_voltage": VoltageFieldAttributes("Internal DC Input Voltage"),
"internal_dc_input_power": PowerFieldAttributes("Internal DC Input Power"),
"internal_dc_input_current": CurrentFieldAttributes("Internal DC Input Current"),
"pv_input_power1": PowerFieldAttributes("Solar Input Power 1"),
"pv_input_power2": PowerFieldAttributes("Solar Input Power 2"),
"pv_input_voltage1": VoltageFieldAttributes("Solar Input Voltage 1"),
"pv_input_voltage2": VoltageFieldAttributes("Solar Input Voltage 2"),
"pv_input_current1": CurrentFieldAttributes("Solar Input Current 1"),
"pv_input_current2": CurrentFieldAttributes("Solar Input Current 2"),
"adl400_ac_input_power_phase1": PowerFieldAttributes(
"AC Solar Input Power Phase 1"
),
"adl400_ac_input_power_phase2": PowerFieldAttributes(
"AC Solar Input Power Phase 2"
),
"adl400_ac_input_power_phase3": PowerFieldAttributes(
"AC Solar Input Power Phase 3"
),
"adl400_ac_input_voltage_phase1": VoltageFieldAttributes(
"AC Solar Input Voltage Phase 1"
),
"adl400_ac_input_voltage_phase2": VoltageFieldAttributes(
"AC Solar Input Voltage Phase 2"
),
"adl400_ac_input_voltage_phase3": VoltageFieldAttributes(
"AC Solar Input Voltage Phase 3"
),
"adl400_ac_input_current_phase1": CurrentFieldAttributes(
"AC Solar Input Current Phase 1"
),
"adl400_ac_input_current_phase2": CurrentFieldAttributes(
"AC Solar Input Current Phase 2"
),
"adl400_ac_input_current_phase3": CurrentFieldAttributes(
"AC Solar Input Current Phase 3"
),
"grid_frequency": FrequencyFieldAttributes("Grid Frequency"),
"grid_power_phase1": PowerFieldAttributes("Grid Power Phase 1"),
"grid_power_phase2": PowerFieldAttributes("Grid Power Phase 2"),
"grid_power_phase3": PowerFieldAttributes("Grid Power Phase 3"),
"grid_voltage_phase1": VoltageFieldAttributes("Grid Voltage Phase 1"),
"grid_voltage_phase2": VoltageFieldAttributes("Grid Voltage Phase 2"),
"grid_voltage_phase3": VoltageFieldAttributes("Grid Voltage Phase 3"),
"grid_current_phase1": CurrentFieldAttributes("Grid Current Phase 1"),
"grid_current_phase2": CurrentFieldAttributes("Grid Current Phase 2"),
"grid_current_phase3": CurrentFieldAttributes("Grid Current Phase 3"),
"ac_output_frequency": FrequencyFieldAttributes("AC Output Frequency"),
"ac_output_power_phase1": PowerFieldAttributes("AC Output Power Phase 1"),
"ac_output_power_phase2": PowerFieldAttributes("AC Output Power Phase 2"),
"ac_output_power_phase3": PowerFieldAttributes("AC Output Power Phase 3"),
"ac_output_voltage_phase1": VoltageFieldAttributes("AC Output Voltage Phase 1"),
"ac_output_voltage_phase2": VoltageFieldAttributes("AC Output Voltage Phase 2"),
"ac_output_voltage_phase3": VoltageFieldAttributes("AC Output Voltage Phase 3"),
"total_ac_consumption": EnergyFieldAttributes("Total Load Consumption"),
"total_grid_consumption": EnergyFieldAttributes("Total Grid Consumption"),
"total_grid_feed": EnergyFieldAttributes("Total Grid Feed"),
# Device specific controls
"power_off": FieldAttributes(
type=FieldType.BUTTON,
setter=True,
name="Power Off",
),
"auto_sleep_mode": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="Screen Auto Sleep Mode",
options=AutoSleepMode,
),
"ups_mode": FieldAttributes(
type=FieldType.ENUM,
setter=False, # Disabled for safety reasons
name="UPS Working Mode",
options=UpsMode,
),
"split_phase_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Split Phase",
),
"split_phase_machine_mode": FieldAttributes(
type=FieldType.ENUM,
setter=False, # Disabled for safety reasons
name="Split Phase Machine Type",
options=SplitPhaseMachineType,
),
"grid_charge_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Grid Charge",
),
"time_control_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Time Control",
),
"battery_range_start": FieldAttributes(
type=FieldType.NUMERIC,
setter=False, # Disabled
name="Battery Range Start",
unit_of_measurement="%",
),
"battery_range_end": FieldAttributes(
type=FieldType.NUMERIC,
setter=False, # Disabled
name="Battery Range End",
unit_of_measurement="%",
),
"led_mode": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="LED Mode",
options=LedMode,
),
"eco_on": FieldAttributes(
type=FieldType.BOOL,
setter=True,
name="Eco Mode",
),
"eco_shutdown": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="Eco Shutdown",
options=EcoShutdown,
),
"charging_mode": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="Charging Mode",
options=ChargingMode,
),
"power_lifting_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Power Lifting",
),
}
def PACK_FIELD_ATTRIBUTES(pack: int):
return {
"pack_voltage": VoltageFieldAttributes(
name=f"Battery Pack {pack} Voltage",
),
"pack_battery_percent": FieldAttributes(
type=FieldType.NUMERIC,
name=f"Battery Pack {pack} Percent",
unit_of_measurement="%",
device_class="battery",
state_class="measurement",
),
"pack_bms_version": FieldAttributes(
type=FieldType.NUMERIC,
name=f"Battery Pack {pack} BMS Version",
),
}
@@ -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
@@ -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("<H", self.cmd, -2, modbus_crc(self.cmd[:-2]))
def response_size(self) -> 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})"
@@ -0,0 +1,49 @@
"""Device builder functions."""
import re
from ..devices.ac60 import AC60
from ..devices.ac200m import AC200M
from ..devices.ac300 import AC300
from ..devices.ac500 import AC500
from ..devices.eb3a import EB3A
from ..devices.ep500 import EP500
from ..devices.ep500p import EP500P
from ..devices.ep600 import EP600
from ..devices.ep760 import EP760
from ..devices.ep800 import EP800
DEVICE_NAME_RE = re.compile(
r"^(AC60|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] == "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 or len(match) < 2:
return "Unknown"
return match[1]
@@ -0,0 +1,204 @@
"""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 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]]
):
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_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
):
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
+18 -6
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import re
import logging
from typing import Any
from bleak import BleakClient
import voluptuous as vol
@@ -12,11 +14,13 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from . import get_type_by_bt_name
from .bluetti_bt_lib.utils.device_builder import get_type_by_bt_name
from .bluetti_bt_lib.bluetooth.device_recognizer import recognize_device
from .const import (
CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN,
@@ -43,6 +47,14 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle bluetooth discovery."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
# Get device type if needed
if isinstance(discovery_info.name, str) and discovery_info.name.startswith("PBOX"):
bleak_device = BleakClient(discovery_info.device)
device_type = await recognize_device(bleak_device, self.hass.loop.create_future)
_LOGGER.info("Device identified as %s", device_type)
discovery_info.name = discovery_info.name.replace("PBOX", device_type.strip())
self._discovery_info = discovery_info
self.context["title_placeholders"] = {"name": discovery_info.name}
return await self.async_step_user()
@@ -57,13 +69,13 @@ 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,
},
)
+15 -510
View File
@@ -1,9 +1,5 @@
"""Constants for the Bluetti BT integration."""
import re
from bluetti_mqtt.mqtt_client import MqttFieldConfig, MqttFieldType
DOMAIN = "bluetti_bt"
MANUFACTURER = "Bluetti"
@@ -17,7 +13,17 @@ CONF_MAX_RETRIES = "max_retries"
DATA_COORDINATOR = "coordinator"
DATA_POLLING_RUNNING = "polling_running"
DEVICE_NAME_RE = re.compile(r'^(AC200M|AC300|AC500|AC60|EP500P|EP500|EP600|EP700|EB3A)(\d+)$')
SUPPORTED_MODELS = [
"AC60",
"AC200M",
"AC300",
"AC500",
"EB3A",
"EP500",
"EP500P",
"EP600",
"EP760",
]
CONTROL_FIELDS = [
"ac_output_on",
@@ -32,509 +38,8 @@ DIAGNOSTIC_FIELDS = [
"bcu_version",
"safety_module_version",
"high_voltage_module_version",
"power_generation",
"total_ac_consumption",
"total_grid_consumption",
"total_grid_feed",
]
ADDITIONAL_DEVICE_FIELDS = {
"max_ac_input_power": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Input Power per Phase",
},
),
"max_ac_input_current": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Input Current per Phase",
},
),
"max_ac_output_power": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Output Power per Phase",
},
),
"max_ac_output_current": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Output Current per Phase",
},
),
"bcu_version": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={"name": "BCU Version"},
),
"safety_module_version": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={"name": "Safety Module Version"},
),
"high_voltage_module_version": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={"name": "High Voltage Safety Module Version"},
),
'pv_input_power1': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Power 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_voltage1': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Voltage 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_current1': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Current 1",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_power2': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Power 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_voltage2': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Voltage 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_current2': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Current 2",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_power_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Power Phase 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_power_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Power Phase 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_power_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Power Phase 3",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_voltage_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Voltage Phase 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_voltage_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Voltage Phase 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_voltage_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Voltage Phase 3",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_voltage_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Voltage Phase 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_voltage_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Voltage Phase 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_voltage_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Voltage Phase 3",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_current_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Current Phase 1",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_current_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Current Phase 2",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_current_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Current Phase 3",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_power_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Power Phase 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_power_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Power Phase 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_power_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Power Phase 3",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_frequency": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Frequency",
"unit_of_measurement": "Hz",
"device_class": "frequency",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_voltage_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Voltage Phase 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_voltage_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Voltage Phase 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_voltage_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Voltage Phase 3",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_power_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Power Phase 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_power_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Power Phase 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_power_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Power Phase 3",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_current_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Current Phase 1",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_current_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Current Phase 2",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_current_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Current Phase 3",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"testing0": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 0",
},
),
"testing1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 1",
},
),
"testing2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 2",
},
),
"testing3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 3",
},
),
"testing4": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 4",
},
),
"testing5": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 5",
},
),
"testing6": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 6",
},
),
"testing7": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 7",
},
),
"testing8": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 8",
},
),
"testing9": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 9",
},
),
}
+14 -191
View File
@@ -1,14 +1,11 @@
"""Coordinator for Bluetti integration."""
from __future__ import annotations
from typing import List, cast
import asyncio
from datetime import timedelta
import logging
import async_timeout
from bleak import BleakClient, BleakError
from bleak import BleakClient
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant
@@ -16,17 +13,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.bluetooth.device_reader import DeviceReader
from .bluetti_bt_lib.utils.device_builder import build_device
from . import build_device
from .const import DATA_POLLING_RUNNING, DOMAIN
from .utils import mac_loggable
_LOGGER = logging.getLogger(__name__)
@@ -52,15 +41,6 @@ class PollingCoordinator(DataUpdateCoordinator):
name="Bluetti polling coordinator",
update_interval=timedelta(seconds=polling_interval),
)
self._address = address
self.has_notifier = False
self.notify_future = None
self.current_command = None
self.notify_response = bytearray()
self.bluetti_device = build_device(address, device_name)
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
# Create client
self.logger.debug("Creating client")
@@ -68,10 +48,17 @@ class PollingCoordinator(DataUpdateCoordinator):
if device is None:
self.logger.error("Device %s not available", mac_loggable(address))
return None
self.client = BleakClient(device)
client = BleakClient(device)
bluetti_device = build_device(address, device_name)
# polling mutex to guard against switches
self.polling_lock = asyncio.Lock()
self.reader = DeviceReader(
client,
bluetti_device,
self.hass.loop.create_future,
persistent_conn=persistent_conn,
polling_timeout=polling_timeout,
max_retries=max_retries,
)
async def _async_update_data(self):
"""Fetch data from API endpoint.
@@ -79,169 +66,5 @@ class PollingCoordinator(DataUpdateCoordinator):
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = True
self.logger.debug("Polling data")
if self.bluetti_device is None:
self.logger.error("Device type for %s not found", mac_loggable(self._address))
return None
parsed_data: dict = {}
async with self.polling_lock:
try:
async with async_timeout.timeout(self.polling_timeout):
# Reconnect if not connected
for attempt in range(1, self.max_retries + 1):
try:
if not self.client.is_connected:
await self.client.connect()
break
except Exception as e:
if attempt == self.max_retries or attempt == 1:
raise # pass exception on max_retries attempt
else:
self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...")
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
BluetoothClient.NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
for command in self.bluetti_device.polling_commands:
try:
body = command.parse_response(
await self.async_send_command(command)
)
self.logger.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
self.logger.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
self.logger.warning("Got a parse exception...")
if len(self.bluetti_device.pack_polling_commands) > 0:
self.logger.debug("Polling battery packs")
# pack polling
for pack in range (1, self.bluetti_device.pack_num_max + 1):
# Set current pack number
await self.async_send_command(
self.bluetti_device.build_setter_command('pack_num', pack)
)
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()
@@ -1,113 +0,0 @@
"""EP600 Field definitions.
Changes made here are only temporary and should also be
contributed to https://github.com/warhammerkid/bluetti_mqtt
"""
from typing import List
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.core.devices.struct import DeviceStruct
from bluetti_mqtt.core.commands import ReadHoldingRegisters
class EP600(BluettiDevice):
def __init__(self, address: str, sn: str):
self.struct = DeviceStruct()
self.struct.add_uint_field('total_battery_percent', 102)
self.struct.add_swap_string_field('device_type', 110, 6)
self.struct.add_sn_field('serial_number', 116)
self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh)
self.struct.add_swap_string_field('device_type', 1101, 6)
self.struct.add_sn_field('serial_number', 1107)
self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh)
self.struct.add_uint_field('battery_range_start', 2022)
self.struct.add_uint_field('battery_range_end', 2023)
self.struct.add_uint_field('max_ac_input_power', 2213)
self.struct.add_uint_field('max_ac_input_current', 2214)
self.struct.add_uint_field('max_ac_output_power', 2215)
self.struct.add_uint_field('max_ac_output_current', 2216)
self.struct.add_swap_string_field('battery_type', 6101, 6)
self.struct.add_sn_field('battery_serial_number', 6107)
self.struct.add_version_field('bcu_version', 6175)
self.struct.add_version_field('bmu_version', 6178)
self.struct.add_version_field('safety_module_version', 6181)
self.struct.add_version_field('high_voltage_module_version', 6184)
# DC Solar Input (copied from PR https://github.com/warhammerkid/bluetti_mqtt/pull/87 by KM011092)
self.struct.add_uint_field('pv_input_power1', 1212) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage1', 1213) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_current1', 1214) # MPP 1 in
self.struct.add_uint_field('pv_input_power2', 1220) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage2', 1221) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_current2', 1222) # MPP 2 in
# ADL400 Smart Meter for AC Solar
self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228)
self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236)
self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1)
# Grid Input
self.struct.add_decimal_field("grid_input_frequency", 1300, 1)
self.struct.add_uint_field("grid_input_power_phase1", 1313)
self.struct.add_uint_field("grid_input_power_phase2", 1319)
self.struct.add_uint_field("grid_input_power_phase3", 1325)
self.struct.add_decimal_field("grid_input_voltage_phase1", 1314, 1)
self.struct.add_decimal_field("grid_input_voltage_phase2", 1320, 1)
self.struct.add_decimal_field("grid_input_voltage_phase3", 1326, 1)
self.struct.add_decimal_field("grid_input_current_phase1", 1315, 1)
self.struct.add_decimal_field("grid_input_current_phase2", 1321, 1)
self.struct.add_decimal_field("grid_input_current_phase3", 1327, 1)
# EP600 AC Output
self.struct.add_decimal_field("ac_output_frequency", 1500, 1)
self.struct.add_uint_field("ac_output_power_phase1", 1510)
self.struct.add_uint_field("ac_output_power_phase2", 1517)
self.struct.add_uint_field("ac_output_power_phase3", 1524)
self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1)
self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1)
self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1)
self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1)
self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1)
self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1)
super().__init__(address, 'EP600', sn)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(1228, 19),
ReadHoldingRegisters(1300, 28),
ReadHoldingRegisters(1500, 27),
ReadHoldingRegisters(2022, 6),
ReadHoldingRegisters(2213, 4),
ReadHoldingRegisters(1212, 11),
# Battery
ReadHoldingRegisters(6101, 7),
ReadHoldingRegisters(6175, 11),
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return []
@property
def logging_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(1100, 51),
ReadHoldingRegisters(1200, 90),
ReadHoldingRegisters(1300, 31),
ReadHoldingRegisters(1400, 48),
ReadHoldingRegisters(1500, 30),
ReadHoldingRegisters(2000, 89),
ReadHoldingRegisters(2200, 41),
ReadHoldingRegisters(2300, 36),
ReadHoldingRegisters(6000, 32),
ReadHoldingRegisters(6100, 100),
ReadHoldingRegisters(6300, 100),
]
@@ -1,67 +0,0 @@
"""EP700 Field definitions.
Changes made here are only temporary and should also be
contributed to https://github.com/warhammerkid/bluetti_mqtt
"""
from typing import List
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.core.devices.struct import DeviceStruct
from bluetti_mqtt.core.commands import ReadHoldingRegisters
class EP700(BluettiDevice):
def __init__(self, address: str, sn: str):
self.struct = DeviceStruct()
# TODO has to be tested
self.struct.add_uint_field('total_battery_percent', 102)
self.struct.add_swap_string_field('device_type', 110, 6)
self.struct.add_sn_field('serial_number', 116)
self.struct.add_decimal_field('power_generation', 154, 1)
self.struct.add_swap_string_field('device_type', 1101, 6)
self.struct.add_sn_field('serial_number', 1107)
self.struct.add_decimal_field('power_generation', 1202, 1)
self.struct.add_uint_field('battery_range_start', 2022)
self.struct.add_uint_field('battery_range_end', 2023)
self.struct.add_uint_field('max_ac_input_power', 2213)
self.struct.add_uint_field('max_ac_input_current', 2214)
self.struct.add_uint_field('max_ac_output_power', 2215)
self.struct.add_uint_field('max_ac_output_current', 2216)
self.struct.add_swap_string_field('battery_type', 6101, 6)
self.struct.add_sn_field('battery_serial_number', 6107)
self.struct.add_version_field('bcu_version', 6175)
self.struct.add_version_field('bmu_version', 6178)
self.struct.add_version_field('safety_module_version', 6181)
self.struct.add_version_field('high_voltage_module_version', 6184)
super().__init__(address, 'EP700', sn)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(2022, 2),
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return []
@property
def logging_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(1100, 51),
ReadHoldingRegisters(1200, 90),
ReadHoldingRegisters(1300, 31),
ReadHoldingRegisters(1400, 48),
ReadHoldingRegisters(1500, 30),
ReadHoldingRegisters(2000, 89),
ReadHoldingRegisters(2200, 41),
ReadHoldingRegisters(2300, 36),
ReadHoldingRegisters(6000, 32),
ReadHoldingRegisters(6100, 100),
ReadHoldingRegisters(6300, 100),
]
+5 -3
View File
@@ -9,7 +9,9 @@
{ "local_name": "EB3A*" },
{ "local_name": "EP500*" },
{ "local_name": "EP600*" },
{ "local_name": "EP700*" }
{ "local_name": "EP760*" },
{ "local_name": "EP800*" },
{ "local_name": "PBOX*" }
],
"codeowners": ["@Patrick762"],
"config_flow": true,
@@ -18,6 +20,6 @@
"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.12"
"requirements": [],
"version": "0.0.18"
}
+19 -29
View File
@@ -6,31 +6,23 @@ from enum import Enum
import logging
from decimal import Decimal
from homeassistant.components.sensor import SensorEntity, CONF_STATE_CLASS
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import (
CONF_ADDRESS,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_DEVICE_CLASS,
EntityCategory,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from bluetti_mqtt.mqtt_client import (
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
battery_pack_fields,
)
from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from .bluetti_bt_lib.utils.device_builder import build_device
from . import device_info as dev_info, get_unique_id, build_device
from .const import DATA_COORDINATOR, DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS
from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, DIAGNOSTIC_FIELDS
from .coordinator import PollingCoordinator
from .utils import unique_id_loggable
@@ -55,45 +47,43 @@ async def async_setup_entry(
bluetti_device = build_device(address, device_name)
sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
all_fields = FIELD_ATTRIBUTES
if len(bluetti_device.pack_polling_commands) > 0:
# add pack fields for device
_LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max)
for pack in range (1, bluetti_device.pack_num_max + 1):
for name, field in battery_pack_fields(pack).items():
for name, field in PACK_FIELD_ATTRIBUTES(pack).items():
all_fields.update({name+str(pack): field})
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key) or field_config.id_override is not None:
if bluetti_device.has_field(field_key):
category = None
if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS:
category = EntityCategory.DIAGNOSTIC
if field_config.type == MqttFieldType.NUMERIC:
if field_config.type == FieldType.NUMERIC:
sensors_to_add.append(
BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
field_config.home_assistant_extra.get(CONF_UNIT_OF_MEASUREMENT),
field_config.home_assistant_extra.get(CONF_DEVICE_CLASS),
field_config.home_assistant_extra.get(CONF_STATE_CLASS),
field_config.name,
field_config.unit_of_measurement,
field_config.device_class,
field_config.state_class,
category=category,
)
)
elif field_config.type == MqttFieldType.ENUM:
elif field_config.type == FieldType.ENUM:
sensors_to_add.append(
BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
options=field_config.home_assistant_extra.get(CONF_OPTIONS),
field_config.name,
options=[o.value for o in field_config.options],
category=category,
)
)
@@ -126,7 +116,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
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
@@ -139,7 +129,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.persistent_conn and not self.coordinator.client.is_connected:
if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return
if self.coordinator.data is None:
+15 -20
View File
@@ -23,16 +23,13 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
)
from bluetti_mqtt.bluetooth.client import BluetoothClient
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.mqtt_client import (
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from .bluetti_bt_lib.base_devices.BluettiDevice import BluettiDevice
from .bluetti_bt_lib.const import WRITE_UUID
from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from .bluetti_bt_lib.utils.device_builder import build_device
from . import device_info as dev_info, get_unique_id, build_device
from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS
from . import device_info as dev_info, get_unique_id
from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN
from .coordinator import PollingCoordinator
from .utils import mac_loggable, unique_id_loggable
@@ -57,12 +54,10 @@ async def async_setup_entry(
bluetti_device = build_device(address, device_name)
sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
all_fields = FIELD_ATTRIBUTES
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key):
if field_config.type == MqttFieldType.BOOL:
if field_config.type == FieldType.BOOL:
if field_config.setter is True and field_key in CONTROL_FIELDS:
sensors_to_add.append(
BluettiSwitch(
@@ -71,7 +66,7 @@ async def async_setup_entry(
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
field_config.name,
entry.entry_id
)
)
@@ -98,8 +93,8 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
self._bluetti_device = bluetti_device
self._coordinator = coordinator
self._client = coordinator.client
self._polling_lock = coordinator.polling_lock
self._client = coordinator.reader.client
self._polling_lock = coordinator.reader.polling_lock
e_name = f"{device_info.get('name')} {name}"
self._address = address
self._response_key = response_key
@@ -107,7 +102,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
self._attr_device_info = device_info
self._attr_has_entity_name = True
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
@@ -117,7 +112,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.persistent_conn and not self.coordinator.client.is_connected:
if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
@@ -169,7 +164,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
# Send command
_LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state)
await self._client.write_gatt_char(
BluetoothClient.WRITE_UUID, bytes(command)
WRITE_UUID, bytes(command)
)
# Wait until device has changed value, otherwise reading register might reset it
@@ -183,7 +178,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
return None
finally:
# Disconnect if connection not persistant
if not self._coordinator.persistent_conn:
if not self._coordinator.reader.persistent_conn:
await self._client.disconnect()
await self.coordinator.async_request_refresh()
@@ -1,4 +1,14 @@
{
"config": {
"step": {
"user": {
"data": {
"address": "Gerät",
"type": "Modell (erforderlich bei PBOX Geräten)"
}
}
}
},
"options": {
"step": {
"init": {
@@ -1,4 +1,14 @@
{
"config": {
"step": {
"user": {
"data": {
"address": "Device",
"type": "Model (required for PBOX devices)"
}
}
}
},
"options": {
"step": {
"init": {