Skip to content

Commit

Permalink
Merge pull request #54 from Patrick762/dev
Browse files Browse the repository at this point in the history
[WIP] Release
  • Loading branch information
Patrick762 authored May 31, 2024
2 parents 7f576df + 03ea349 commit 7da392d
Show file tree
Hide file tree
Showing 40 changed files with 2,237 additions and 922 deletions.
13 changes: 2 additions & 11 deletions .github/ISSUE_TEMPLATE/bug_report.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,10 @@ body:
default: 0
validations:
required: true
- type: dropdown
- type: textarea
id: device
attributes:
label: What device are you seeing the problem on?
multiple: true
options:
- AC60
- AC200M
- AC300
- AC500
- EB3A
- EP500
- EP500P
- EP900
validations:
required: true
- type: dropdown
Expand All @@ -55,6 +45,7 @@ body:
options:
- USB dongle
- ESPHome bluetooth proxy
- Internal bluetooth adapter
validations:
required: true
- type: dropdown
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

Bluetti Integration for Home Assistant

Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt).
## Disclaimer
This integration is provided without any warranty or support by Bluetti (unfortunately). I do not take responsibility for any problems it may cause in all cases. Use it at your own risk.

## Installation
To install this integration, you first need [HACS](https://hacs.xyz/) installed.
Expand All @@ -14,19 +15,20 @@ After the installation, you can use this button to install the integration:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Patrick762&repository=hassio-bluetti-bt&category=integration)

### Supported devices:
(based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices))

- AC60
- AC180 (basic data)
- AC180P (basic data)
- AC200L (untested)
- AC200M
- AC300 (tested)
- AC500
- EB3A
- EP500
- EP500P
- EP600 (tested)

### Available sensors:
All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)).
- EP760 (basic data)
- EP800 (basic data)

### Available controls:
If enabled in the Integration options (you need to reload the integration if you change this option):
Expand Down
29 changes: 8 additions & 21 deletions custom_components/bluetti_bt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import re
import logging
from typing import List

from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
Expand All @@ -25,13 +26,15 @@
)
from .coordinator import PollingCoordinator

PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
PLATFORMS: List[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bluetti Powerstation from a config entry."""

_LOGGER.debug("Init Bluetti BT Integration")

address = entry.data.get(CONF_ADDRESS)
device_name = entry.data.get(CONF_NAME)
use_controls = entry.data.get(CONF_USE_CONTROLS)
Expand All @@ -52,10 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False)

# Create coordinator for polling
_LOGGER.debug("Creating coordinator")
coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator)

_LOGGER.debug("Creating entities")
platforms: list = PLATFORMS
if use_controls is True:
_LOGGER.warning("You are using controls with this integration at your own risk!")
Expand All @@ -64,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Setup platforms
await hass.config_entries.async_forward_entry_setups(entry, platforms)

_LOGGER.debug("Setup done")

return True


Expand All @@ -83,23 +90,3 @@ def get_unique_id(name: str, sensor_type: str | None = None):
if sensor_type is not None:
return f"{sensor_type}.{res}"
return res


def get_type_by_bt_name(bt_name: str):
"""Get the device type."""
dev_type = "Unknown"
if bt_name.startswith("AC200M"):
dev_type = "AC200M"
elif bt_name.startswith("AC300"):
dev_type = "AC300"
elif bt_name.startswith("AC500"):
dev_type = "AC500"
elif bt_name.startswith("AC60"):
dev_type = "AC60"
elif bt_name.startswith("EB3A"):
dev_type = "EB3A"
elif bt_name.startswith("EP500"):
dev_type = "EP500"
elif bt_name.startswith("EP600"):
dev_type = "EP600"
return dev_type
41 changes: 20 additions & 21 deletions custom_components/bluetti_bt/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,12 @@
CoordinatorEntity,
)

from bluetti_mqtt.bluetooth import build_device
from bluetti_mqtt.mqtt_client import (
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, FieldType
from .bluetti_bt_lib.utils.device_builder import build_device

from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS
from .coordinator import PollingCoordinator, DummyDevice
from .const import DATA_COORDINATOR, DOMAIN, CONF_USE_CONTROLS
from .coordinator import PollingCoordinator
from .utils import unique_id_loggable

_LOGGER = logging.getLogger(__name__)
Expand All @@ -40,6 +36,7 @@ async def async_setup_entry(

device_name = entry.data.get(CONF_NAME)
address = entry.data.get(CONF_ADDRESS)
use_controls = entry.data.get(CONF_USE_CONTROLS, False)
if address is None:
_LOGGER.error("Device has no address")

Expand All @@ -49,27 +46,22 @@ async def async_setup_entry(

# Add sensors according to device_info
bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)

sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
all_fields = FIELD_ATTRIBUTES
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key):
if field_config.type == MqttFieldType.BOOL:
category = None
if field_config.type == FieldType.BOOL:
if field_config.setter is True:
category = EntityCategory.DIAGNOSTIC
continue

sensors_to_add.append(
BluettiBinarySensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
category=category,
field_config.name,
)
)

Expand All @@ -86,26 +78,30 @@ def __init__(
address,
response_key: str,
name: str,
category: EntityCategory | None = None,
):
"""Init battery entity."""
super().__init__(coordinator)

self._attr_has_entity_name = True
e_name = f"{device_info.get('name')} {name}"
self._address = address
self._response_key = response_key

self._attr_device_info = device_info
self._attr_name = e_name
self._attr_name = name
self._attr_available = False
self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category

@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""

if self.coordinator.persistent_conn and not self.coordinator.client.is_connected:
if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return

_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
Expand All @@ -114,11 +110,13 @@ def _handle_coordinator_update(self) -> None:
"Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id)
)
self._attr_available = False
self.async_write_ha_state()
return

response_data = self.coordinator.data.get(self._response_key)
if response_data is None:
self._attr_available = False
self.async_write_ha_state()
return

if not isinstance(response_data, bool):
Expand All @@ -128,6 +126,7 @@ def _handle_coordinator_update(self) -> None:
response_data,
)
self._attr_available = False
self.async_write_ha_state()
return

self._attr_available = True
Expand Down
3 changes: 3 additions & 0 deletions custom_components/bluetti_bt/bluetti_bt_lib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .const import *
from .exceptions import *
from .utils.commands import ReadHoldingRegisters
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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),
]
Loading

0 comments on commit 7da392d

Please sign in to comment.