Merge pull request #54 from Patrick762/dev

[WIP] Release
This commit is contained in:
Patrick762 authored and GitHub committed 2024-05-31 17:05:16 +02:00
commit 7da392d85a
40 files changed
+2237 -922

No files matched your search

+2 -11
View File
@@ -31,20 +31,10 @@ body:
default: 0 default: 0
validations: validations:
required: true required: true
- type: dropdown - type: textarea
id: device id: device
attributes: attributes:
label: What device are you seeing the problem on? label: What device are you seeing the problem on?
multiple: true
options:
- AC60
- AC200M
- AC300
- AC500
- EB3A
- EP500
- EP500P
- EP900
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@@ -55,6 +45,7 @@ body:
options: options:
- USB dongle - USB dongle
- ESPHome bluetooth proxy - ESPHome bluetooth proxy
- Internal bluetooth adapter
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
+7 -5
View File
@@ -5,7 +5,8 @@
Bluetti Integration for Home Assistant Bluetti Integration for Home Assistant
Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt). ## Disclaimer
This integration is provided without any warranty or support by Bluetti (unfortunately). I do not take responsibility for any problems it may cause in all cases. Use it at your own risk.
## Installation ## Installation
To install this integration, you first need [HACS](https://hacs.xyz/) installed. To install this integration, you first need [HACS](https://hacs.xyz/) installed.
@@ -14,9 +15,11 @@ After the installation, you can use this button to install the integration:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Patrick762&repository=hassio-bluetti-bt&category=integration) [![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: ### Supported devices:
(based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices))
- AC60 - AC60
- AC180 (basic data)
- AC180P (basic data)
- AC200L (untested)
- AC200M - AC200M
- AC300 (tested) - AC300 (tested)
- AC500 - AC500
@@ -24,9 +27,8 @@ After the installation, you can use this button to install the integration:
- EP500 - EP500
- EP500P - EP500P
- EP600 (tested) - EP600 (tested)
- EP760 (basic data)
### Available sensors: - EP800 (basic data)
All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)).
### Available controls: ### Available controls:
If enabled in the Integration options (you need to reload the integration if you change this option): If enabled in the Integration options (you need to reload the integration if you change this option):
+8 -21
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import re import re
import logging import logging
from typing import List
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -25,13 +26,15 @@ from .const import (
) )
from .coordinator import PollingCoordinator from .coordinator import PollingCoordinator
PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS: List[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bluetti Powerstation from a config entry.""" """Set up Bluetti Powerstation from a config entry."""
_LOGGER.debug("Init Bluetti BT Integration")
address = entry.data.get(CONF_ADDRESS) address = entry.data.get(CONF_ADDRESS)
device_name = entry.data.get(CONF_NAME) device_name = entry.data.get(CONF_NAME)
use_controls = entry.data.get(CONF_USE_CONTROLS) use_controls = entry.data.get(CONF_USE_CONTROLS)
@@ -52,10 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False) hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False)
# Create coordinator for polling # Create coordinator for polling
_LOGGER.debug("Creating coordinator")
coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries) coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator) hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator)
_LOGGER.debug("Creating entities")
platforms: list = PLATFORMS platforms: list = PLATFORMS
if use_controls is True: if use_controls is True:
_LOGGER.warning("You are using controls with this integration at your own risk!") _LOGGER.warning("You are using controls with this integration at your own risk!")
@@ -64,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Setup platforms # Setup platforms
await hass.config_entries.async_forward_entry_setups(entry, platforms) await hass.config_entries.async_forward_entry_setups(entry, platforms)
_LOGGER.debug("Setup done")
return True return True
@@ -83,23 +90,3 @@ def get_unique_id(name: str, sensor_type: str | None = None):
if sensor_type is not None: if sensor_type is not None:
return f"{sensor_type}.{res}" return f"{sensor_type}.{res}"
return res return res
def get_type_by_bt_name(bt_name: str):
"""Get the device type."""
dev_type = "Unknown"
if bt_name.startswith("AC200M"):
dev_type = "AC200M"
elif bt_name.startswith("AC300"):
dev_type = "AC300"
elif bt_name.startswith("AC500"):
dev_type = "AC500"
elif bt_name.startswith("AC60"):
dev_type = "AC60"
elif bt_name.startswith("EB3A"):
dev_type = "EB3A"
elif bt_name.startswith("EP500"):
dev_type = "EP500"
elif bt_name.startswith("EP600"):
dev_type = "EP600"
return dev_type
+20 -21
View File
@@ -18,16 +18,12 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
) )
from bluetti_mqtt.bluetooth import build_device from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, FieldType
from bluetti_mqtt.mqtt_client import ( from .bluetti_bt_lib.utils.device_builder import build_device
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS from .const import DATA_COORDINATOR, DOMAIN, CONF_USE_CONTROLS
from .coordinator import PollingCoordinator, DummyDevice from .coordinator import PollingCoordinator
from .utils import unique_id_loggable from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -40,6 +36,7 @@ async def async_setup_entry(
device_name = entry.data.get(CONF_NAME) device_name = entry.data.get(CONF_NAME)
address = entry.data.get(CONF_ADDRESS) address = entry.data.get(CONF_ADDRESS)
use_controls = entry.data.get(CONF_USE_CONTROLS, False)
if address is None: if address is None:
_LOGGER.error("Device has no address") _LOGGER.error("Device has no address")
@@ -49,18 +46,14 @@ async def async_setup_entry(
# Add sensors according to device_info # Add sensors according to device_info
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)
sensors_to_add = [] sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS all_fields = FIELD_ATTRIBUTES
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
for field_key, field_config in all_fields.items(): for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key): 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: if field_config.setter is True:
category = EntityCategory.DIAGNOSTIC continue
sensors_to_add.append( sensors_to_add.append(
BluettiBinarySensor( BluettiBinarySensor(
@@ -68,8 +61,7 @@ async def async_setup_entry(
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
category=category,
) )
) )
@@ -86,26 +78,30 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
address, address,
response_key: str, response_key: str,
name: str, name: str,
category: EntityCategory | None = None,
): ):
"""Init battery entity.""" """Init battery entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_has_entity_name = True
e_name = f"{device_info.get('name')} {name}" e_name = f"{device_info.get('name')} {name}"
self._address = address self._address = address
self._response_key = response_key self._response_key = response_key
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_name = name
self._attr_available = False self._attr_available = False
self._attr_unique_id = get_unique_id(e_name) self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """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 return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
@@ -114,11 +110,13 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
"Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id) "Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
response_data = self.coordinator.data.get(self._response_key) response_data = self.coordinator.data.get(self._response_key)
if response_data is None: if response_data is None:
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
if not isinstance(response_data, bool): if not isinstance(response_data, bool):
@@ -128,6 +126,7 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
response_data, response_data,
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
self._attr_available = True self._attr_available = True
@@ -0,0 +1,3 @@
from .const import *
from .exceptions import *
from .utils.commands import ReadHoldingRegisters
@@ -0,0 +1,70 @@
"""Bluetti device."""
# Reduced copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/bluetti_device.py
from typing import Any, List
from ..utils.commands import ReadHoldingRegisters, WriteSingleRegister
from ..utils.struct import BoolField, DeviceStruct, EnumField
class BluettiDevice:
struct: DeviceStruct
def __init__(self, address: str, type: str, sn: str):
self.address = address
self.type = type
self.sn = sn
def parse(self, address: int, data: bytes) -> dict:
return self.struct.parse(address, data)
@property
def pack_num_max(self):
"""
A given device has a maximum number of battery packs, including the
internal battery if it has one. We can provide this information statically
so it's not necessary to poll the device.
"""
return 1
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device has an optimal set of commands for polling"""
raise NotImplementedError
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device may have a set of commands for polling pack data"""
return []
@property
def writable_ranges(self) -> List[range]:
"""The address ranges that are writable"""
return []
@property
def pack_num_field(self) -> List[ReadHoldingRegisters]:
"""The address 'range' of the pack num result. Matches pack_num_result"""
return []
def has_field(self, field: str):
return any(f.name == field for f in self.struct.fields)
def has_field_setter(self, field: str):
matches = [f for f in self.struct.fields if f.name == field]
return any(any(f.address in r for r in self.writable_ranges) for f in matches)
def build_setter_command(self, field: str, value: Any):
matches = [f for f in self.struct.fields if f.name == field]
device_field = next(
f for f in matches if any(f.address in r for r in self.writable_ranges)
)
# Convert value to an integer
if isinstance(device_field, EnumField):
value = device_field.enum[value].value
elif isinstance(device_field, BoolField):
value = 1 if value else 0
return WriteSingleRegister(device_field.address, value)
@@ -0,0 +1,64 @@
"""Base device definition for V1 Protocol devices."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..utils.struct import DeviceStruct
from .BluettiDevice import BluettiDevice
class ProtocolV1Device(BluettiDevice):
def __init__(self, address: str, type: str, sn: str):
self.struct = DeviceStruct()
# Device info
self.struct.add_string_field("device_type", 10, 6)
self.struct.add_sn_field("serial_number", 17)
# Power IO
self.struct.add_uint_field("dc_input_power", 36)
self.struct.add_uint_field("ac_input_power", 37)
self.struct.add_uint_field("ac_output_power", 38)
self.struct.add_uint_field("dc_output_power", 39)
# Power IO statistics
self.struct.add_decimal_field(
"power_generation", 41, 1
) # Total power generated since last reset (kwh)
# Battery
self.struct.add_uint_field("total_battery_percent", 43)
# Output state
self.struct.add_bool_field("ac_output_on", 48)
self.struct.add_bool_field("dc_output_on", 49)
# Pack selector
self.struct.add_uint_field("pack_num", 3006) # internal
# Output controls
self.struct.add_bool_field("ac_output_on_switch", 3007)
self.struct.add_bool_field("dc_output_on_switch", 3008)
super().__init__(address, type, sn)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(10, 10),
ReadHoldingRegisters(36, 4),
ReadHoldingRegisters(41, 1),
ReadHoldingRegisters(43, 1),
ReadHoldingRegisters(48, 2),
ReadHoldingRegisters(3007, 2),
]
@property
def writable_ranges(self) -> List[range]:
return [range(3006, 3009)]
@property
def pack_num_field(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(96, 1),
]
@@ -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,215 @@
"""Device reader."""
import asyncio
import logging
from typing import Any, Callable, List, cast
import async_timeout
from bleak import BleakClient, BleakError
from ..base_devices.BluettiDevice import BluettiDevice
from ..const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID
from ..exceptions import BadConnectionError, ModbusError, ParseError
from ..utils.commands import ReadHoldingRegisters
_LOGGER = logging.getLogger(__name__)
class DeviceReader:
def __init__(
self,
bleak_client: BleakClient,
bluetti_device: BluettiDevice,
future_builder_method: Callable[[], asyncio.Future[Any]],
persistent_conn: bool = False,
polling_timeout: int = 45,
max_retries: int = 5,
) -> None:
self.client = bleak_client
self.bluetti_device = bluetti_device
self.create_future = future_builder_method
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
self.has_notifier = False
self.notify_future: asyncio.Future[Any] | None = None
self.current_command = None
self.notify_response = bytearray()
# polling mutex to guard against switches
self.polling_lock = asyncio.Lock()
async def read_data(
self, filter_registers: List[ReadHoldingRegisters] | None = None
) -> dict | None:
_LOGGER.info("Reading data")
if self.bluetti_device is None:
_LOGGER.error("Device is None")
return None
polling_commands = self.bluetti_device.polling_commands
pack_commands = self.bluetti_device.pack_polling_commands
if filter_registers is not None:
polling_commands = filter_registers
pack_commands = []
parsed_data: dict = {}
async with self.polling_lock:
try:
async with async_timeout.timeout(self.polling_timeout):
# Reconnect if not connected
for attempt in range(1, self.max_retries + 1):
try:
if not self.client.is_connected:
await self.client.connect()
break
except Exception as e:
if attempt == self.max_retries or attempt == 1:
raise # pass exception on max_retries attempt
else:
_LOGGER.warning(
f"Connect unsucessful (attempt {attempt}): {e}. Retrying..."
)
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
# Execute polling commands
for command in polling_commands:
try:
body = command.parse_response(
await self._async_send_command(command)
)
_LOGGER.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
_LOGGER.warning("Got a parse exception")
# Execute pack polling commands
if len(pack_commands) > 0 and len(self.bluetti_device.pack_num_field) == 1:
_LOGGER.debug("Polling battery packs")
for pack in range(1, self.bluetti_device.pack_num_max + 1):
_LOGGER.debug("Setting pack_num to %i", pack)
# Set current pack number
command = self.bluetti_device.build_setter_command(
"pack_num", pack
)
body = command.parse_response(
await self._async_send_command(command)
)
_LOGGER.debug("Raw data set: %s", body)
# b'\x00\x02' b'\x00\x03' here (seems like result)
for command in pack_commands:
# Request & parse result for each pack
try:
body = command.parse_response(
await self._async_send_command(command)
)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
for key, value in parsed.items():
parsed_data.update({key + str(pack): value})
except ParseError:
_LOGGER.warning("Got a parse exception...")
except TimeoutError:
_LOGGER.warning("Polling timed out. Trying again later")
return None
except BleakError as err:
_LOGGER.error("Bleak error: %s", err)
return None
finally:
# Disconnect if connection not persistant
if not self.persistent_conn:
if self.has_notifier:
try:
await self.client.stop_notify(NOTIFY_UUID)
except:
# Ignore errors here
pass
self.has_notifier = False
await self.client.disconnect()
# Check if dict is empty
if not parsed_data:
return None
return parsed_data
async def _async_send_command(self, command: ReadHoldingRegisters) -> bytes:
"""Send command and return response"""
try:
# Prepare to make request
self.current_command = command
self.notify_future = self.create_future()
self.notify_response = bytearray()
# Make request
_LOGGER.debug("Requesting %s", command)
await self.client.write_gatt_char(WRITE_UUID, bytes(command))
# Wait for response
res = await asyncio.wait_for(self.notify_future, timeout=RESPONSE_TIMEOUT)
# Process data
_LOGGER.debug("Got %s bytes", len(res))
return cast(bytes, res)
except TimeoutError:
_LOGGER.debug("Polling single command timed out")
except ModbusError as err:
_LOGGER.debug(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
# Ignore other errors
pass
# caught an exception, return empty bytes object
return bytes()
def _notification_handler(self, _sender: int, data: bytearray):
"""Handle bt data."""
# Ignore notifications we don't expect
if self.notify_future is None or self.notify_future.done():
_LOGGER.warning("Unexpected notification")
return
# If something went wrong, we might get weird data.
if data == b"AT+NAME?\r" or data == b"AT+ADV?\r":
err = BadConnectionError("Got AT+ notification")
self.notify_future.set_exception(err)
return
# Save data
self.notify_response.extend(data)
if len(self.notify_response) == self.current_command.response_size():
if self.current_command.is_valid_response(self.notify_response):
self.notify_future.set_result(self.notify_response)
else:
self.notify_future.set_exception(ParseError("Failed checksum"))
elif self.current_command.is_exception_response(self.notify_response):
# We got a MODBUS command exception
msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}"
self.notify_future.set_exception(ModbusError(msg))
@@ -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,8 @@
"""AC180 fields."""
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC180(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC180", sn)
@@ -0,0 +1,8 @@
"""AC180P fields."""
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC180P(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC180P", sn)
@@ -0,0 +1,60 @@
"""AC200L fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC200L(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC200L", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls
self.struct.add_bool_field("power_off", 3060)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 3
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 5),
ReadHoldingRegisters(86, 3),
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3060, 2),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [range(3060, 3062)]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16)
]
@@ -0,0 +1,60 @@
"""AC200M fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC200M(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC200M", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls
self.struct.add_bool_field("power_off", 3060)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 3
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 5),
ReadHoldingRegisters(86, 3),
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3060, 2),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [range(3060, 3062)]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16)
]
@@ -0,0 +1,84 @@
"""AC300 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC300(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC300", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_int_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_int_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_int_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("pv_input_voltage1", 86, multiplier=.1)
self.struct.add_decimal_field("pv_input_power1", 87, 1, multiplier=10)
self.struct.add_decimal_field("pv_input_current1", 88, 2, multiplier=10)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 4
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 11),
ReadHoldingRegisters(86, 3),
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3017),
range(3061, 3062),
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,82 @@
"""AC500 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC500(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC500", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_int_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_int_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_int_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("pv_input_voltage1", 86, multiplier=.1)
self.struct.add_decimal_field("pv_input_power1", 87, 1, multiplier=10)
self.struct.add_decimal_field("pv_input_current1", 88, 2, multiplier=10)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 4
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 11),
ReadHoldingRegisters(86, 3),
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3017),
range(3061, 3062),
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
]
@@ -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,42 @@
"""EB3A fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import ChargingMode, EcoShutdown, LedMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EB3A(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EB3A", sn)
# Details
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
# Controls
self.struct.add_enum_field("led_mode", 3034, LedMode)
self.struct.add_bool_field("power_off", 3060)
self.struct.add_bool_field("eco_on", 3063)
self.struct.add_enum_field("eco_shutdown", 3064, EcoShutdown)
self.struct.add_enum_field("charging_mode", 3065, ChargingMode)
self.struct.add_bool_field("power_lifting_on", 3066)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(77, 1),
ReadHoldingRegisters(86, 1),
ReadHoldingRegisters(3034, 1),
ReadHoldingRegisters(3060, 1),
ReadHoldingRegisters(3063, 5),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3034, 3035),
range(3060, 3061),
range(3063, 3067),
]
@@ -0,0 +1,78 @@
"""EP500 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EP500(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP500", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 11),
ReadHoldingRegisters(86, 3),
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3017),
range(3061, 3062),
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
]
@@ -0,0 +1,78 @@
"""EP500P fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EP500P(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP500P", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
# Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(70, 11),
ReadHoldingRegisters(86, 3),
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3001, 3017),
range(3061, 3062),
]
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(91, 2),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
]
@@ -0,0 +1,124 @@
"""EP600 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class EP600(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP600", sn)
# Details
self.struct.add_uint_field("battery_range_start", 2022)
self.struct.add_uint_field("battery_range_end", 2023)
self.struct.add_uint_field("max_ac_input_power", 2213)
self.struct.add_uint_field("max_ac_input_current", 2214)
self.struct.add_uint_field("max_ac_output_power", 2215)
self.struct.add_uint_field("max_ac_output_current", 2216)
# DC Solar
self.struct.add_uint_field("pv_input_power1", 1212)
self.struct.add_decimal_field("pv_input_voltage1", 1213, 1)
self.struct.add_decimal_field("pv_input_current1", 1214, 1)
self.struct.add_uint_field("pv_input_power2", 1220)
self.struct.add_decimal_field("pv_input_voltage2", 1221, 1)
self.struct.add_decimal_field("pv_input_current2", 1222, 1)
# ADL400 Smart Meter for AC Solar
self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228)
self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236)
self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1)
# Testing required
self.struct.add_uint_field("adl400_ac_input_current_phase1", 1230)
self.struct.add_uint_field("adl400_ac_input_current_phase2", 1238)
self.struct.add_uint_field("adl400_ac_input_current_phase3", 1246)
# Grid Input
self.struct.add_decimal_field("grid_frequency", 1300, 1)
self.struct.add_uint_field("grid_power_phase1", 1313)
self.struct.add_uint_field("grid_power_phase2", 1319)
self.struct.add_uint_field("grid_power_phase3", 1325)
self.struct.add_decimal_field("grid_voltage_phase1", 1314, 1)
self.struct.add_decimal_field("grid_voltage_phase2", 1320, 1)
self.struct.add_decimal_field("grid_voltage_phase3", 1326, 1)
self.struct.add_decimal_field("grid_current_phase1", 1315, 1)
self.struct.add_decimal_field("grid_current_phase2", 1321, 1)
self.struct.add_decimal_field("grid_current_phase3", 1327, 1)
# EP600 AC Output
self.struct.add_decimal_field("ac_output_frequency", 1500, 1)
self.struct.add_int_field("ac_output_power_phase1", 1510)
self.struct.add_int_field("ac_output_power_phase2", 1517)
self.struct.add_int_field("ac_output_power_phase3", 1524)
self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1)
self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1)
self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1)
self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1)
self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1)
self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1)
# KM - Consumption
self.struct.add_int_field("consumption_power_phase1", 1430)
self.struct.add_int_field("consumption_power_phase2", 1436)
self.struct.add_int_field("consumption_power_phase3", 1442)
self.struct.add_decimal_field("consumption_voltage_phase1", 1431, 1)
self.struct.add_decimal_field("consumption_voltage_phase2", 1437, 1)
self.struct.add_decimal_field("consumption_voltage_phase3", 1443, 1)
self.struct.add_decimal_field("consumption_current_phase1", 1432, 1)
self.struct.add_decimal_field("consumption_current_phase2", 1438, 1)
self.struct.add_decimal_field("consumption_current_phase3", 1444, 1)
# KM - Totals
self.struct.add_uint_field('pv_input_power_all', 144) # Total PV in
self.struct.add_uint_field('consumption_power_all', 142) # Total AC out
self.struct.add_uint_field('grid_power_all', 146) # Total Grid in - value only +/- unknown
# Statistics
self.struct.add_decimal_field(
"total_ac_consumption", 152, 1
) # Load consumption
self.struct.add_decimal_field(
"total_grid_consumption", 156, 1
) # Grid consumption stats
self.struct.add_decimal_field("total_grid_feed", 158, 1)
# Battery packs
self.struct.add_swap_string_field("battery_type", 6101, 6) # internal
self.struct.add_sn_field("battery_serial_number", 6107) # internal
self.struct.add_version_field("bcu_version", 6175) # internal
self.struct.add_version_field("bmu_version", 6178) # internal
self.struct.add_version_field("safety_module_version", 6181) # internal
self.struct.add_version_field("high_voltage_module_version", 6184) # internal
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(2022, 2),
ReadHoldingRegisters(2213, 5),
ReadHoldingRegisters(1212, 3),
ReadHoldingRegisters(1220, 3),
ReadHoldingRegisters(1228, 3),
ReadHoldingRegisters(1236, 3),
ReadHoldingRegisters(1244, 3),
ReadHoldingRegisters(1300, 1),
ReadHoldingRegisters(1313, 3),
ReadHoldingRegisters(1319, 3),
ReadHoldingRegisters(1325, 3),
ReadHoldingRegisters(1500, 1),
ReadHoldingRegisters(1510, 3),
ReadHoldingRegisters(1517, 3),
ReadHoldingRegisters(1524, 3),
ReadHoldingRegisters(152, 1),
ReadHoldingRegisters(156, 1),
ReadHoldingRegisters(158, 1),
# KM - Consumption
ReadHoldingRegisters(1430, 15),
# KM - Totals
ReadHoldingRegisters(142, 5),
]
@@ -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,338 @@
"""Field attributes for device fields."""
from enum import Enum, auto, unique
from .field_enums import (
AutoSleepMode,
ChargingMode,
EcoShutdown,
LedMode,
OutputMode,
SplitPhaseMachineType,
UpsMode,
)
@unique
class FieldType(Enum):
NUMERIC = auto()
BOOL = auto()
ENUM = auto()
class FieldAttributes:
def __init__(
self,
type: FieldType = FieldType.NUMERIC,
setter: bool = False,
name: str = "",
unit_of_measurement: str | None = None,
device_class: str | None = None,
state_class: str | None = None,
options: Enum | None = None,
):
self.type = type
self.setter = setter
self.name = name
self.unit_of_measurement = unit_of_measurement
self.device_class = device_class
self.state_class = state_class
self.options = options
class PowerFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="W",
device_class="power",
state_class="measurement",
)
class VoltageFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="V",
device_class="voltage",
state_class="measurement",
)
class CurrentFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="A",
device_class="current",
state_class="measurement",
)
class EnergyFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="kWh",
device_class="energy",
state_class="total_increasing",
)
class FrequencyFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
):
super().__init__(
name=name,
unit_of_measurement="Hz",
device_class="frequency",
state_class="measurement",
)
class OutletFieldAttributes(FieldAttributes):
def __init__(
self,
name: str = "",
setter: bool = False,
):
super().__init__(
type=FieldType.BOOL,
name=name,
setter=setter,
device_class="outlet",
)
FIELD_ATTRIBUTES: dict[str, FieldAttributes] = {
# Base device fields
"dc_input_power": PowerFieldAttributes("DC Input Power"),
"ac_input_power": PowerFieldAttributes("AC Input Power"),
"ac_output_power": PowerFieldAttributes("AC Output Power"),
"dc_output_power": PowerFieldAttributes("DC Output Power"),
"power_generation": EnergyFieldAttributes("Total Power Generation"),
"total_battery_percent": FieldAttributes(
type=FieldType.NUMERIC,
name="Total Battery Percent",
unit_of_measurement="%",
device_class="battery",
state_class="measurement",
),
"total_battery_voltage": VoltageFieldAttributes("Total Battery Voltage"),
"ac_output_on": OutletFieldAttributes("AC Output"),
"dc_output_on": OutletFieldAttributes("DC Output"),
# Controls
"ac_output_on_switch": OutletFieldAttributes(
name="AC Output",
setter=True,
),
"dc_output_on_switch": OutletFieldAttributes(
name="DC Output",
setter=True,
),
# Device specific fields
"ac_output_mode": FieldAttributes(
type=FieldType.ENUM,
name="AC Output Mode",
options=OutputMode,
),
"internal_ac_voltage": VoltageFieldAttributes("Internal AC Voltage"),
"internal_current_one": CurrentFieldAttributes("Internal Current Sensor 1"),
"internal_power_one": PowerFieldAttributes("Internal Power Sensor 1"),
"internal_ac_frequency": FrequencyFieldAttributes("Internal AC Frequency"),
"internal_current_two": CurrentFieldAttributes("Internal Current Sensor 2"),
"internal_power_two": PowerFieldAttributes("Internal Power Sensor 2"),
"ac_input_voltage": VoltageFieldAttributes("AC Input Voltage"),
"internal_current_three": CurrentFieldAttributes("Internal Current Sensor 3"),
"internal_power_three": PowerFieldAttributes("Internal Power Sensor 3"),
"ac_input_frequency": FrequencyFieldAttributes("AC Input Frequency"),
"internal_dc_input_voltage": VoltageFieldAttributes("Internal DC Input Voltage"),
"internal_dc_input_power": PowerFieldAttributes("Internal DC Input Power"),
"internal_dc_input_current": CurrentFieldAttributes("Internal DC Input Current"),
"pv_input_power1": PowerFieldAttributes("Solar Input Power 1"),
"pv_input_power2": PowerFieldAttributes("Solar Input Power 2"),
"pv_input_voltage1": VoltageFieldAttributes("Solar Input Voltage 1"),
"pv_input_voltage2": VoltageFieldAttributes("Solar Input Voltage 2"),
"pv_input_current1": CurrentFieldAttributes("Solar Input Current 1"),
"pv_input_current2": CurrentFieldAttributes("Solar Input Current 2"),
"adl400_ac_input_power_phase1": PowerFieldAttributes(
"AC Solar Input Power Phase 1"
),
"adl400_ac_input_power_phase2": PowerFieldAttributes(
"AC Solar Input Power Phase 2"
),
"adl400_ac_input_power_phase3": PowerFieldAttributes(
"AC Solar Input Power Phase 3"
),
"adl400_ac_input_voltage_phase1": VoltageFieldAttributes(
"AC Solar Input Voltage Phase 1"
),
"adl400_ac_input_voltage_phase2": VoltageFieldAttributes(
"AC Solar Input Voltage Phase 2"
),
"adl400_ac_input_voltage_phase3": VoltageFieldAttributes(
"AC Solar Input Voltage Phase 3"
),
"adl400_ac_input_current_phase1": CurrentFieldAttributes(
"AC Solar Input Current Phase 1"
),
"adl400_ac_input_current_phase2": CurrentFieldAttributes(
"AC Solar Input Current Phase 2"
),
"adl400_ac_input_current_phase3": CurrentFieldAttributes(
"AC Solar Input Current Phase 3"
),
"grid_frequency": FrequencyFieldAttributes("Grid Frequency"),
"grid_power_phase1": PowerFieldAttributes("Grid Power Phase 1"),
"grid_power_phase2": PowerFieldAttributes("Grid Power Phase 2"),
"grid_power_phase3": PowerFieldAttributes("Grid Power Phase 3"),
"grid_voltage_phase1": VoltageFieldAttributes("Grid Voltage Phase 1"),
"grid_voltage_phase2": VoltageFieldAttributes("Grid Voltage Phase 2"),
"grid_voltage_phase3": VoltageFieldAttributes("Grid Voltage Phase 3"),
"grid_current_phase1": CurrentFieldAttributes("Grid Current Phase 1"),
"grid_current_phase2": CurrentFieldAttributes("Grid Current Phase 2"),
"grid_current_phase3": CurrentFieldAttributes("Grid Current Phase 3"),
"ac_output_frequency": FrequencyFieldAttributes("AC Output Frequency"),
"ac_output_power_phase1": PowerFieldAttributes("AC Output Power Phase 1"),
"ac_output_power_phase2": PowerFieldAttributes("AC Output Power Phase 2"),
"ac_output_power_phase3": PowerFieldAttributes("AC Output Power Phase 3"),
"ac_output_voltage_phase1": VoltageFieldAttributes("AC Output Voltage Phase 1"),
"ac_output_voltage_phase2": VoltageFieldAttributes("AC Output Voltage Phase 2"),
"ac_output_voltage_phase3": VoltageFieldAttributes("AC Output Voltage Phase 3"),
"total_ac_consumption": EnergyFieldAttributes("Total Load Consumption"),
"total_grid_consumption": EnergyFieldAttributes("Total Grid Consumption"),
"total_grid_feed": EnergyFieldAttributes("Total Grid Feed"),
# KM - Consumption
"consumption_power_phase1": PowerFieldAttributes("Consumption Power Phase 1"),
"consumption_power_phase2": PowerFieldAttributes("Consumption Power Phase 2"),
"consumption_power_phase3": PowerFieldAttributes("Consumption Power Phase 3"),
"consumption_voltage_phase1": VoltageFieldAttributes("Consumption Voltage Phase 1"),
"consumption_voltage_phase2": VoltageFieldAttributes("Consumption Voltage Phase 2"),
"consumption_voltage_phase3": VoltageFieldAttributes("Consumption Voltage Phase 3"),
"consumption_current_phase1": CurrentFieldAttributes("Consumption Current Phase 1"),
"consumption_current_phase2": CurrentFieldAttributes("Consumption Current Phase 2"),
"consumption_current_phase3": CurrentFieldAttributes("Consumption Current Phase 3"),
# KM - Consumption end
# KM - Totals
"pv_input_power_all": PowerFieldAttributes("PV Input Power All"),
"consumption_power_all": PowerFieldAttributes("Consumption Power All"),
"grid_power_all": PowerFieldAttributes("Grid Power All"),
# KM - Totals end
# Device specific controls
"power_off": FieldAttributes(
type=FieldType.BOOL,
setter=True,
name="Power Off",
),
"auto_sleep_mode": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="Screen Auto Sleep Mode",
options=AutoSleepMode,
),
"ups_mode": FieldAttributes(
type=FieldType.ENUM,
setter=False, # Disabled for safety reasons
name="UPS Working Mode",
options=UpsMode,
),
"split_phase_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Split Phase",
),
"split_phase_machine_mode": FieldAttributes(
type=FieldType.ENUM,
setter=False, # Disabled for safety reasons
name="Split Phase Machine Type",
options=SplitPhaseMachineType,
),
"grid_charge_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Grid Charge",
),
"time_control_on": FieldAttributes(
type=FieldType.BOOL,
setter=False, # Disabled for safety reasons
name="Time Control",
),
"battery_range_start": FieldAttributes(
type=FieldType.NUMERIC,
setter=False, # Disabled
name="Battery Range Start",
unit_of_measurement="%",
),
"battery_range_end": FieldAttributes(
type=FieldType.NUMERIC,
setter=False, # Disabled
name="Battery Range End",
unit_of_measurement="%",
),
"led_mode": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="LED Mode",
options=LedMode,
),
"eco_on": FieldAttributes(
type=FieldType.BOOL,
setter=True,
name="Eco Mode",
),
"eco_shutdown": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="Eco Shutdown",
options=EcoShutdown,
),
"charging_mode": FieldAttributes(
type=FieldType.ENUM,
setter=True,
name="Charging Mode",
options=ChargingMode,
),
"power_lifting_on": FieldAttributes(
type=FieldType.BOOL,
setter=True,
name="Power Lifting",
),
}
def PACK_FIELD_ATTRIBUTES(pack: int):
return {
"pack_voltage": VoltageFieldAttributes(
name=f"Battery Pack {pack} Voltage",
),
"pack_battery_percent": FieldAttributes(
type=FieldType.NUMERIC,
name=f"Battery Pack {pack} Percent",
unit_of_measurement="%",
device_class="battery",
state_class="measurement",
),
"pack_bms_version": FieldAttributes(
type=FieldType.NUMERIC,
name=f"Battery Pack {pack} BMS Version",
),
}
@@ -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,58 @@
"""Device builder functions."""
import re
from ..devices.ac60 import AC60
from ..devices.ac180 import AC180
from ..devices.ac180p import AC180P
from ..devices.ac200l import AC200L
from ..devices.ac200m import AC200M
from ..devices.ac300 import AC300
from ..devices.ac500 import AC500
from ..devices.eb3a import EB3A
from ..devices.ep500 import EP500
from ..devices.ep500p import EP500P
from ..devices.ep600 import EP600
from ..devices.ep760 import EP760
from ..devices.ep800 import EP800
DEVICE_NAME_RE = re.compile(
r"^(AC60|AC180|AC180P|AC200L|AC200M|AC300|AC500|EB3A|EP500|EP500P|EP600|EP760|EP800)(\d+)$"
)
def build_device(address: str, name: str):
match = DEVICE_NAME_RE.match(name)
if match[1] == "AC60":
return AC60(address, match[2])
if match[1] == "AC180":
return AC180(address, match[2])
if match[1] == "AC180P":
return AC180P(address, match[2])
if match[1] == "AC200L":
return AC200L(address, match[2])
if match[1] == "AC200M":
return AC200M(address, match[2])
if match[1] == "AC300":
return AC300(address, match[2])
if match[1] == "AC500":
return AC500(address, match[2])
if match[1] == "EB3A":
return EB3A(address, match[2])
if match[1] == "EP500":
return EP500(address, match[2])
if match[1] == "EP500P":
return EP500P(address, match[2])
if match[1] == "EP600":
return EP600(address, match[2])
if match[1] == "EP760":
return EP760(address, match[2])
if match[1] == "EP800":
return EP800(address, match[2])
def get_type_by_bt_name(bt_name: str):
match = DEVICE_NAME_RE.match(bt_name)
if match is None:
return "Unknown"
return match[1]
@@ -0,0 +1,208 @@
"""Struct definitions."""
# Copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/struct.py
from decimal import Decimal
from enum import Enum
import struct
from typing import Any, List, Optional, Tuple, Type
def swap_bytes(data: bytes):
"""Swaps the place of every other byte, returning a new byte array"""
arr = bytearray(data)
for i in range(0, len(arr) - 1, 2):
arr[i], arr[i + 1] = arr[i + 1], arr[i]
return arr
class DeviceField:
def __init__(self, name: str, address: int, size: int):
self.name = name
self.address = address
self.size = size
def parse(self, data: bytes) -> Any:
raise NotImplementedError
def in_range(self, val: Any) -> bool:
return True
class UintField(DeviceField):
def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]], multiplier: float):
self.range = range
self.multiplier = multiplier
super().__init__(name, address, 1)
def parse(self, data: bytes) -> int:
val = struct.unpack("!H", data)[0]
return val * self.multiplier
def in_range(self, val: int) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class IntField(DeviceField):
def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]]):
self.range = range
super().__init__(name, address, 1)
def parse(self, data: bytes) -> int:
return struct.unpack(">h", data)[0]
def in_range(self, val: int) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class BoolField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 1)
def parse(self, data: bytes) -> bool:
return struct.unpack("!H", data)[0] == 1
class EnumField(DeviceField):
def __init__(self, name: str, address: int, enum: Type[Enum]):
self.enum = enum
super().__init__(name, address, 1)
def parse(self, data: bytes) -> Any:
val = struct.unpack("!H", data)[0]
return self.enum(val)
class DecimalField(DeviceField):
def __init__(
self, name: str, address: int, scale: int, range: Optional[Tuple[int, int]], multiplier: float
):
self.scale = scale
self.range = range
self.multiplier = multiplier
super().__init__(name, address, 1)
def parse(self, data: bytes) -> Decimal:
val = Decimal(struct.unpack("!H", data)[0])
return (val / 10 ** self.scale) * self.multiplier
def in_range(self, val: Decimal) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class DecimalArrayField(DeviceField):
def __init__(self, name: str, address: int, size: int, scale: int):
self.scale = scale
super().__init__(name, address, size)
def parse(self, data: bytes) -> Decimal:
values = list(struct.unpack(f"!{self.size}H", data))
return [Decimal(v) / 10 ** self.scale for v in values]
class StringField(DeviceField):
"""Fixed-width null-terminated string field"""
def parse(self, data: bytes) -> str:
return data.rstrip(b"\0").decode("ascii")
class SwapStringField(DeviceField):
"""Fixed-width null-terminated string field"""
def parse(self, data: bytes) -> str:
return swap_bytes(data).rstrip(b"\0").decode("ascii")
class VersionField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 2)
def parse(self, data: bytes) -> int:
values = struct.unpack("!2H", data)
return Decimal(values[0] + (values[1] << 16)) / 100
class SerialNumberField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 4)
def parse(self, data: bytes) -> int:
values = struct.unpack("!4H", data)
return values[0] + (values[1] << 16) + (values[2] << 32) + (values[3] << 48)
class DeviceStruct:
fields: List[DeviceField]
def __init__(self):
self.fields = []
def add_uint_field(self, name: str, address: int, range: Tuple[int, int] = None, multiplier: float = 1):
self.fields.append(UintField(name, address, range, multiplier))
def add_int_field(self, name: str, address: int, range: Tuple[int, int] = None):
self.fields.append(IntField(name, address, range))
def add_bool_field(self, name: str, address: int):
self.fields.append(BoolField(name, address))
def add_enum_field(self, name: str, address: int, enum: Type[Enum]):
self.fields.append(EnumField(name, address, enum))
def add_decimal_field(
self, name: str, address: int, scale: int, range: Tuple[int, int] = None, multiplier: float = 1
):
self.fields.append(DecimalField(name, address, scale, range, multiplier))
def add_decimal_array_field(self, name: str, address: int, size: int, scale: int):
self.fields.append(DecimalArrayField(name, address, size, scale))
def add_string_field(self, name: str, address: int, size: int):
self.fields.append(StringField(name, address, size))
def add_swap_string_field(self, name: str, address: int, size: int):
self.fields.append(SwapStringField(name, address, size))
def add_version_field(self, name: str, address: int):
self.fields.append(VersionField(name, address))
def add_sn_field(self, name: str, address: int):
self.fields.append(SerialNumberField(name, address))
def parse(self, starting_address: int, data: bytes) -> dict:
# Offsets and size are counted in 2 byte chunks, so for the range we
# need to divide the byte size by 2
data_size = int(len(data) / 2)
# Filter out fields not in range
r = range(starting_address, starting_address + data_size)
fields = [
f for f in self.fields if f.address in r and f.address + f.size - 1 in r
]
# Parse fields
parsed = {}
for f in fields:
data_start = 2 * (f.address - starting_address)
field_data = data[data_start : data_start + 2 * f.size]
val = f.parse(field_data)
# Skip if the value is "out-of-range" - sometimes the sensors
# report weird values
if not f.in_range(val):
continue
parsed[f.name] = val
return parsed
+28 -6
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import re import re
import logging import logging
from typing import Any
from bleak import BleakClient
import voluptuous as vol import voluptuous as vol
@@ -12,11 +14,13 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak, BluetoothServiceInfoBleak,
async_discovered_service_info, async_discovered_service_info,
) )
from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector 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 ( from .const import (
CONF_MAX_RETRIES, CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN, CONF_PERSISTENT_CONN,
@@ -43,6 +47,23 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle bluetooth discovery.""" """Handle bluetooth discovery."""
await self.async_set_unique_id(discovery_info.address) await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
if isinstance(discovery_info.name, str):
name = re.sub("[^A-Z0-9]+", "", discovery_info.name)
discovery_info.manufacturer_data = {
CONF_TYPE: get_type_by_bt_name(name)
}
# Get device type if needed
if isinstance(discovery_info.name, str) and discovery_info.name.startswith("PBOX"):
bleak_device = BleakClient(discovery_info.device)
device_type = await recognize_device(bleak_device, self.hass.loop.create_future)
_LOGGER.info("Device identified as %s", device_type)
discovery_info.manufacturer_data = {
CONF_TYPE: device_type.strip(),
}
discovery_info.name = discovery_info.name.replace("PBOX", device_type.strip())
self._discovery_info = discovery_info self._discovery_info = discovery_info
self.context["title_placeholders"] = {"name": discovery_info.name} self.context["title_placeholders"] = {"name": discovery_info.name}
return await self.async_step_user() return await self.async_step_user()
@@ -57,13 +78,14 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
discovery_info = self._discovered_devices[address] discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(address, raise_on_progress=False) await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured() 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( return self.async_create_entry(
title=discovery_info.name, title=name,
data={ data={
CONF_ADDRESS: discovery_info.address, CONF_ADDRESS: discovery_info.address,
CONF_TYPE: dev_type, CONF_NAME: name,
CONF_NAME: re.sub("[^A-Z0-9]+", "", discovery_info.name), CONF_TYPE: discovery_info.manufacturer_data.get(CONF_TYPE, "Unknown"),
}, },
) )
+21 -509
View File
@@ -1,7 +1,5 @@
"""Constants for the Bluetti BT integration.""" """Constants for the Bluetti BT integration."""
from bluetti_mqtt.mqtt_client import MqttFieldConfig, MqttFieldType
DOMAIN = "bluetti_bt" DOMAIN = "bluetti_bt"
MANUFACTURER = "Bluetti" MANUFACTURER = "Bluetti"
@@ -15,9 +13,24 @@ CONF_MAX_RETRIES = "max_retries"
DATA_COORDINATOR = "coordinator" DATA_COORDINATOR = "coordinator"
DATA_POLLING_RUNNING = "polling_running" DATA_POLLING_RUNNING = "polling_running"
SUPPORTED_MODELS = [
"AC60",
"AC200M",
"AC300",
"AC500",
"EB3A",
"EP500",
"EP500P",
"EP600",
"EP760",
]
CONTROL_FIELDS = [ CONTROL_FIELDS = [
"ac_output_on", "ac_output_on_switch",
"dc_output_on", "dc_output_on_switch",
"power_off",
"eco_on",
"power_lifting_on",
] ]
DIAGNOSTIC_FIELDS = [ DIAGNOSTIC_FIELDS = [
@@ -28,509 +41,8 @@ DIAGNOSTIC_FIELDS = [
"bcu_version", "bcu_version",
"safety_module_version", "safety_module_version",
"high_voltage_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",
},
),
}
+19 -284
View File
@@ -1,14 +1,11 @@
"""Coordinator for Bluetti integration.""" """Coordinator for Bluetti integration."""
from __future__ import annotations from __future__ import annotations
from typing import List, cast
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import async_timeout
from bleak import BleakClient, BleakError from bleak import BleakClient
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -16,115 +13,14 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator, DataUpdateCoordinator,
) )
from bluetti_mqtt.bluetooth.client import BluetoothClient from .bluetti_bt_lib.bluetooth.device_reader import DeviceReader
from bluetti_mqtt.bluetooth import ( from .bluetti_bt_lib.utils.device_builder import build_device
BadConnectionError,
ModbusError,
ParseError,
build_device,
)
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.core.commands import ReadHoldingRegisters
from .const import DATA_POLLING_RUNNING, DOMAIN
from .utils import mac_loggable from .utils import mac_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class DummyDevice(BluettiDevice):
"""Dummy device used to add more fields to existing devices.
Changes made here are only temporary and should also be
contributed to https://github.com/warhammerkid/bluetti_mqtt
"""
def __init__(self, device: BluettiDevice):
"""Init dummy device with real device."""
self.struct = device.struct
if device.type == "EP600":
# DC Solar Input (copied from PR https://github.com/warhammerkid/bluetti_mqtt/pull/87 by KM011092)
self.struct.add_uint_field('pv_input_power1', 1212) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage1', 1213) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_current1', 1214) # MPP 1 in
self.struct.add_uint_field('pv_input_power2', 1220) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage2', 1221) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_current2', 1222) # MPP 2 in
# ADL400 Smart Meter for AC Solar
self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228)
self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236)
self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1)
# Grid Input
self.struct.add_decimal_field("grid_input_frequency", 1300, 1)
self.struct.add_uint_field("grid_input_power_phase1", 1313)
self.struct.add_uint_field("grid_input_power_phase2", 1319)
self.struct.add_uint_field("grid_input_power_phase3", 1325)
self.struct.add_decimal_field("grid_input_voltage_phase1", 1314, 1)
self.struct.add_decimal_field("grid_input_voltage_phase2", 1320, 1)
self.struct.add_decimal_field("grid_input_voltage_phase3", 1326, 1)
self.struct.add_decimal_field("grid_input_current_phase1", 1315, 1)
self.struct.add_decimal_field("grid_input_current_phase2", 1321, 1)
self.struct.add_decimal_field("grid_input_current_phase3", 1327, 1)
# EP600 AC Output
self.struct.add_decimal_field("ac_output_frequency", 1500, 1)
self.struct.add_uint_field("ac_output_power_phase1", 1510)
self.struct.add_uint_field("ac_output_power_phase2", 1517)
self.struct.add_uint_field("ac_output_power_phase3", 1524)
self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1)
self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1)
self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1)
self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1)
self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1)
self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1)
super().__init__(device.address, device.type, device.sn)
self._parent = device
@property
def pack_num_max(self):
return self._parent.pack_num_max
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
if self.type == "EP600":
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(1228, 19),
ReadHoldingRegisters(1300, 28),
ReadHoldingRegisters(1500, 27),
ReadHoldingRegisters(2022, 6),
ReadHoldingRegisters(2213, 4),
ReadHoldingRegisters(1212, 11),
# Battery
ReadHoldingRegisters(6101, 7),
ReadHoldingRegisters(6175, 11),
]
return self._parent.polling_commands
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
if self.type == "EP600":
return []
return self._parent.pack_polling_commands
@property
def logging_commands(self) -> List[ReadHoldingRegisters]:
return self._parent.logging_commands
@property
def pack_logging_commands(self) -> List[ReadHoldingRegisters]:
return self._parent.pack_logging_commands
@property
def writable_ranges(self) -> List[range]:
return self._parent.writable_ranges
class PollingCoordinator(DataUpdateCoordinator): class PollingCoordinator(DataUpdateCoordinator):
"""Polling coordinator.""" """Polling coordinator."""
@@ -145,18 +41,8 @@ class PollingCoordinator(DataUpdateCoordinator):
name="Bluetti polling coordinator", name="Bluetti polling coordinator",
update_interval=timedelta(seconds=polling_interval), update_interval=timedelta(seconds=polling_interval),
) )
self._address = address
self.has_notifier = False
self.notify_future = None
self.current_command = None
self.notify_response = bytearray()
bluetti_device = build_device(address, device_name)
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
# Add or modify device fields self.address = address
self.bluetti_device = DummyDevice(bluetti_device)
# Create client # Create client
self.logger.debug("Creating client") self.logger.debug("Creating client")
@@ -164,10 +50,17 @@ class PollingCoordinator(DataUpdateCoordinator):
if device is None: if device is None:
self.logger.error("Device %s not available", mac_loggable(address)) self.logger.error("Device %s not available", mac_loggable(address))
return None return None
self.client = BleakClient(device) client = BleakClient(device)
bluetti_device = build_device(address, device_name)
# polling mutex to guard against switches self.reader = DeviceReader(
self.polling_lock = asyncio.Lock() 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): async def _async_update_data(self):
"""Fetch data from API endpoint. """Fetch data from API endpoint.
@@ -175,169 +68,11 @@ class PollingCoordinator(DataUpdateCoordinator):
This is the place to pre-process the data to lookup tables This is the place to pre-process the data to lookup tables
so entities can quickly look up their data. 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") # Check if device is connected
if bluetooth.async_address_present(self.hass, self.address, connectable=True) is False:
if self.bluetti_device is None: self.logger.warning("Device not connected")
self.logger.error("Device type for %s not found", mac_loggable(self._address)) self.last_update_success = False
return None return None
parsed_data: dict = {} return await self.reader.read_data()
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))
+10 -5
View File
@@ -2,21 +2,26 @@
"domain": "bluetti_bt", "domain": "bluetti_bt",
"name": "Bluetti BT", "name": "Bluetti BT",
"bluetooth": [ "bluetooth": [
{ "local_name": "AC60*" },
{ "local_name": "AC180*" },
{ "local_name": "AC200L*" },
{ "local_name": "AC200M*" }, { "local_name": "AC200M*" },
{ "local_name": "AC300*" }, { "local_name": "AC300*" },
{ "local_name": "AC500*" }, { "local_name": "AC500*" },
{ "local_name": "AC60*" },
{ "local_name": "EB3A*" }, { "local_name": "EB3A*" },
{ "local_name": "EP500*" }, { "local_name": "EP500*" },
{ "local_name": "EP600*" } { "local_name": "EP600*" },
{ "local_name": "EP760*" },
{ "local_name": "EP800*" },
{ "local_name": "PBOX*" }
], ],
"codeowners": ["@Patrick762"], "codeowners": ["@Patrick762"],
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://github.com/Patrick762/hassio-bluetti-bt", "documentation": "https://github.com/Patrick762/hassio-bluetti-bt",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues",
"requirements": ["bluetti_mqtt==0.15.0"], "requirements": [],
"version": "0.0.11" "version": "0.1.0"
} }
+34 -37
View File
@@ -6,33 +6,24 @@ from enum import Enum
import logging import logging
from decimal import Decimal 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.const import ( from homeassistant.const import (
CONF_ADDRESS, CONF_ADDRESS,
CONF_NAME, CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_DEVICE_CLASS,
EntityCategory, EntityCategory,
) )
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity
CoordinatorEntity,
)
from bluetti_mqtt.bluetooth import build_device from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from bluetti_mqtt.mqtt_client import ( from .bluetti_bt_lib.utils.device_builder import build_device
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
battery_pack_fields,
)
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS from .const import DATA_COORDINATOR, DOMAIN, DIAGNOSTIC_FIELDS
from .coordinator import PollingCoordinator, DummyDevice from .coordinator import PollingCoordinator
from .utils import unique_id_loggable from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -54,48 +45,45 @@ async def async_setup_entry(
# Add sensors according to device_info # Add sensors according to device_info
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)
sensors_to_add = [] sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS all_fields = FIELD_ATTRIBUTES
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
if len(bluetti_device.pack_polling_commands) > 0: if len(bluetti_device.pack_polling_commands) > 0:
# add pack fields for device # add pack fields for device
_LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max) _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 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}) all_fields.update({name+str(pack): field})
for field_key, field_config in all_fields.items(): for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key) or field_config.id_override is not None: if bluetti_device.has_field(field_key) or (len(bluetti_device.pack_polling_commands) > 0 and field_key.startswith("pack_")):
category = None category = None
if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS: if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS:
category = EntityCategory.DIAGNOSTIC category = EntityCategory.DIAGNOSTIC
if field_config.type == MqttFieldType.NUMERIC: if field_config.type == FieldType.NUMERIC:
sensors_to_add.append( sensors_to_add.append(
BluettiSensor( BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
field_config.home_assistant_extra.get(CONF_UNIT_OF_MEASUREMENT), field_config.unit_of_measurement,
field_config.home_assistant_extra.get(CONF_DEVICE_CLASS), field_config.device_class,
field_config.home_assistant_extra.get(CONF_STATE_CLASS), field_config.state_class,
category=category, category=category,
) )
) )
elif field_config.type == MqttFieldType.ENUM: elif field_config.type == FieldType.ENUM:
sensors_to_add.append( sensors_to_add.append(
BluettiSensor( BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
options=field_config.home_assistant_extra.get(CONF_OPTIONS), options=[o.value for o in field_config.options],
category=category, category=category,
) )
) )
@@ -122,12 +110,13 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
"""Init battery entity.""" """Init battery entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_has_entity_name = True
e_name = f"{device_info.get('name')} {name}" e_name = f"{device_info.get('name')} {name}"
self._address = address self._address = address
self._response_key = response_key self._response_key = response_key
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_name = name
self._attr_available = False self._attr_available = False
self._attr_unique_id = get_unique_id(e_name) self._attr_unique_id = get_unique_id(e_name)
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
@@ -136,18 +125,24 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
self._attr_entity_category = category self._attr_entity_category = category
self._options = options self._options = options
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """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 return
if self.coordinator.data is None: if self.coordinator.data is None:
_LOGGER.warning( _LOGGER.debug(
"Data from coordinator is None. Skipping update for %s", "Data from coordinator is None",
unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False
self.async_write_ha_state()
return return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
@@ -156,12 +151,14 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
"Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id) "Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
response_data = self.coordinator.data.get(self._response_key) response_data = self.coordinator.data.get(self._response_key)
if response_data is None: if response_data is None:
_LOGGER.warning("No data for available for (%s)", self._response_key) _LOGGER.debug("No data for available for (%s)", self._response_key)
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
if ( if (
@@ -178,6 +175,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
type(response_data), type(response_data),
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
self._attr_available = True self._attr_available = True
@@ -186,10 +184,9 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
if ( if (
self._options is not None self._options is not None
and isinstance(response_data, Enum) and isinstance(response_data, Enum)
and response_data.value < len(self._options)
): ):
# Enum # Enum
self._attr_native_value = self._options[response_data.value] self._attr_native_value = response_data.name
else: else:
# Numeric # Numeric
self._attr_native_value = response_data self._attr_native_value = response_data
+25 -23
View File
@@ -23,18 +23,14 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
) )
from bluetti_mqtt.bluetooth import build_device from .bluetti_bt_lib.base_devices.BluettiDevice import BluettiDevice
from bluetti_mqtt.bluetooth.client import BluetoothClient from .bluetti_bt_lib.const import WRITE_UUID
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from bluetti_mqtt.mqtt_client import ( from .bluetti_bt_lib.utils.device_builder import build_device
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN
from .coordinator import PollingCoordinator, DummyDevice from .coordinator import PollingCoordinator
from .utils import mac_loggable, unique_id_loggable from .utils import mac_loggable, unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -56,15 +52,12 @@ async def async_setup_entry(
# Add sensors according to device_info # Add sensors according to device_info
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)
sensors_to_add = [] sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS all_fields = FIELD_ATTRIBUTES
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
for field_key, field_config in all_fields.items(): for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key): 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: if field_config.setter is True and field_key in CONTROL_FIELDS:
sensors_to_add.append( sensors_to_add.append(
BluettiSwitch( BluettiSwitch(
@@ -73,7 +66,7 @@ async def async_setup_entry(
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
entry.entry_id entry.entry_id
) )
) )
@@ -100,38 +93,46 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
self._bluetti_device = bluetti_device self._bluetti_device = bluetti_device
self._coordinator = coordinator self._coordinator = coordinator
self._client = coordinator.client self._client = coordinator.reader.client
self._polling_lock = coordinator.polling_lock self._polling_lock = coordinator.reader.polling_lock
e_name = f"{device_info.get('name')} {name}" e_name = f"{device_info.get('name')} {name}"
self._address = address self._address = address
self._response_key = response_key self._response_key = response_key
self._entry_id = entry_id self._entry_id = entry_id
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_has_entity_name = True
self._attr_name = name
self._attr_available = False self._attr_available = False
self._attr_unique_id = get_unique_id(e_name) self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category self._attr_entity_category = category
self._attr_device_class = SwitchDeviceClass.OUTLET self._attr_device_class = SwitchDeviceClass.OUTLET
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """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 return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
if not isinstance(self.coordinator.data, dict): if not isinstance(self.coordinator.data, dict):
_LOGGER.error( _LOGGER.debug(
"Invalid data from coordinator (switch.%s)", unique_id_loggable(self._attr_unique_id) "Invalid data from coordinator (switch.%s)", unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
response_data = self.coordinator.data.get(self._response_key) response_data = self.coordinator.data.get(self._response_key)
if response_data is None: if response_data is None:
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
if not isinstance(response_data, bool): if not isinstance(response_data, bool):
@@ -141,6 +142,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
response_data, response_data,
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
self._attr_available = True self._attr_available = True
@@ -170,7 +172,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
# Send command # Send command
_LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state) _LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state)
await self._client.write_gatt_char( 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 # Wait until device has changed value, otherwise reading register might reset it
@@ -184,7 +186,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
return None return None
finally: finally:
# Disconnect if connection not persistant # Disconnect if connection not persistant
if not self._coordinator.persistent_conn: if not self._coordinator.reader.persistent_conn:
await self._client.disconnect() await self._client.disconnect()
await self.coordinator.async_request_refresh() 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": { "options": {
"step": { "step": {
"init": { "init": {
@@ -1,4 +1,14 @@
{ {
"config": {
"step": {
"user": {
"data": {
"address": "Device",
"type": "Model (required for PBOX devices)"
}
}
}
},
"options": { "options": {
"step": { "step": {
"init": { "init": {
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Execute unittests of python library
python3 -m unittest discover -s tests -p "*_test.py"
+124
View File
@@ -0,0 +1,124 @@
"""Unittest for device builder."""
import unittest
from custom_components.bluetti_bt.bluetti_bt_lib.utils.device_builder import build_device
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac60 import AC60
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac180 import AC180
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac180p import AC180P
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac200m import AC200M
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac300 import AC300
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac500 import AC500
from custom_components.bluetti_bt.bluetti_bt_lib.devices.eb3a import EB3A
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep500 import EP500
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep500p import EP500P
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep600 import EP600
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep760 import EP760
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ep800 import EP800
class TestDeviceBuilder(unittest.TestCase):
def test_build_Unknow(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "PBOX56786746478"
with self.assertRaises(TypeError):
build_device(bt_addr, bt_name)
def test_build_ac60(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC6056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC60)
self.assertEqual(built.address, bt_addr)
def test_build_ac180(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC18056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC180)
self.assertEqual(built.address, bt_addr)
def test_build_ac180p(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC180P56786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC180P)
self.assertEqual(built.address, bt_addr)
def test_build_ac200m(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC200M56786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC200M)
self.assertEqual(built.address, bt_addr)
def test_build_ac300(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC30056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC300)
self.assertEqual(built.address, bt_addr)
def test_build_ac500(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC50056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC500)
self.assertEqual(built.address, bt_addr)
def test_build_eb3a(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "EB3A56786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, EB3A)
self.assertEqual(built.address, bt_addr)
def test_build_ep500(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "EP50056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, EP500)
self.assertEqual(built.address, bt_addr)
def test_build_ep500p(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "EP500P56786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, EP500P)
self.assertEqual(built.address, bt_addr)
def test_build_EP600(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "EP60056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, EP600)
self.assertEqual(built.address, bt_addr)
def test_build_EP760(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "EP76056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, EP760)
self.assertEqual(built.address, bt_addr)
def test_build_EP800(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "EP80056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, EP800)
self.assertEqual(built.address, bt_addr)
if __name__ == '__main__':
unittest.main()