Compare commits

..
66 Commits
Author SHA1 Message Date
Patrick762 528378ed9f Merge pull request #19 from Patrick762/dev
Version 0.0.11
2024-01-05 20:58:12 +01:00
Patrick762 b7c162aaef updated readme 2024-01-04 16:02:49 +01:00
Patrick762 dda0d475f4 stop notifier before disconnect 2024-01-04 15:39:02 +01:00
Patrick762 98f004dad1 revert to previous version number 2024-01-04 15:16:15 +01:00
Patrick762 515a06dc3a added more config options 2024-01-04 15:14:36 +01:00
Patrick762 14f2219b94 updated version number, added logging 2023-12-17 21:08:28 +01:00
Patrick762 4710a8183f replaced deploy script 2023-12-17 16:55:41 +01:00
Patrick762 186d73ace1 Merge pull request #24 from ViPeR5000/patch-1
Create pt.json
2023-12-17 16:53:58 +01:00
Rui Melo 7c211cde6f Create pt.json 2023-12-13 21:11:54 +00:00
Patrick762 4f99b7d746 Added install instructions for integration 2023-12-11 19:55:44 +01:00
Patrick762 70d7403aec Added stale bot action 2023-12-10 16:50:47 +01:00
Patrick762 33fe91c2d2 added check if packs available 2023-09-23 19:45:02 +02:00
Patrick762 7904a07e7f added ep600 dc input, prevent pack polling 2023-09-23 17:52:18 +02:00
Patrick762 18e48092c0 Merge pull request #15 from SupaFlav/dev
Add battery pack percent & voltage
2023-09-23 15:47:55 +02:00
Patrick762 792da76a04 Merge pull request #18 from Patrick762/Patrick762-patch-1
Update README.md
2023-09-23 15:26:25 +02:00
Patrick762 107ac9f0c8 Update README.md 2023-09-23 15:25:42 +02:00
SupaFlav 3d8cf4b98f Add 5x reconnect attempts on poll 2023-09-08 19:56:12 -04:00
SupaFlav c3afdcfe89 Allow switches with persistent_conn and add mutex to guard polling 2023-09-03 23:33:26 -04:00
SupaFlav d38c30011c return empty bytes object vs None on exception 2023-09-01 09:51:57 -04:00
SupaFlav 32cfc34c37 refactor send command & return response within new func 2023-08-31 23:42:00 -04:00
SupaFlav 7c25cbbe27 remove commentary 2023-08-30 18:13:51 -04:00
SupaFlav 57d0969701 Merge pull request #1 from Patrick762/dev
Pull latest from upstream
2023-08-30 15:42:27 -04:00
SupaFlav b8a73e0ab9 add polling for battery pack voltage & percent 2023-08-30 15:34:22 -04:00
Patrick762 39c75b0f75 update version number 2023-08-30 21:02:39 +02:00
Patrick762 3dc1cff551 Merge pull request #14 from SupaFlav/dev
Remove redundant connection attempt & don't process callback data on dead connection with persistent_conn active
2023-08-30 20:58:58 +02:00
SupaFlav 296c9cbfd3 Ensure this only applies to persistent connections 2023-08-28 15:33:45 -04:00
SupaFlav 0cadbb9931 Remove extra connection attempt, rm callback debug login when not connected 2023-08-28 15:03:43 -04:00
Patrick762 3a662964b8 Merge pull request #13 from Patrick762/dev
Config options
2023-08-26 20:31:22 +02:00
Patrick762 43be1e1dbe added utility to make macs loggable 2023-08-26 18:01:05 +02:00
Patrick762 e0c7ada147 updated version number 2023-08-26 16:00:41 +02:00
Patrick762 cf43c55cb8 fixed error on first device setup 2023-08-26 15:56:10 +02:00
Patrick762 a5ced35d32 added validation for polling interval 2023-08-26 15:54:33 +02:00
Patrick762 15216e1268 added option for persistant connection 2023-08-26 15:25:14 +02:00
Patrick762 ed10127a90 added option to set polling interval 2023-08-26 14:44:03 +02:00
Patrick762 4c286324c7 Merge pull request #12 from Patrick762/dev
increased version number
2023-08-19 22:42:07 +02:00
Patrick762 6361e661c0 increased version number 2023-08-19 22:41:39 +02:00
Patrick762 0d237f2e45 Merge pull request #11 from Patrick762/dev
Improvements and bug fixes
2023-08-19 22:40:40 +02:00
Patrick762 95f2331cc3 fixed enum sensors 2023-08-19 22:38:30 +02:00
Patrick762 61bc379d3a updated readme 2023-08-19 21:20:32 +02:00
Patrick762 dd7665f069 added option to turn on and off outputs 2023-08-19 21:17:44 +02:00
Patrick762 8fce8270b2 added switches for ac and dc on off 2023-08-19 17:38:24 +02:00
Patrick762 886ab957bb added options flow 2023-08-19 17:06:58 +02:00
Patrick762 5a6dbb8d00 added "my" link to readme 2023-08-19 17:05:59 +02:00
Patrick762 d54bf7c526 Merge pull request #7 from Patrick762/dev
Added more EP600 fields
2023-08-04 19:49:33 +02:00
Patrick762 22c2f0c7d0 edited comment 2023-08-04 19:49:22 +02:00
Patrick762 23dc2b7f41 renamed some fields, added grid input power fields 2023-08-04 19:47:42 +02:00
Patrick762 84a742968a added more ep600 fields 2023-08-04 19:27:58 +02:00
Patrick762 ed818070c2 Merge pull request #6 from Patrick762/dev
Updated EP600 fields
2023-08-03 22:01:00 +02:00
Patrick762 beb4e6b8fb added input and output frequency for ep600 2023-08-03 21:58:59 +02:00
Patrick762 7444097c60 added more ep600 voltage entities 2023-08-03 21:44:51 +02:00
Patrick762 0912fcbc49 Merge pull request #5 from Patrick762/sensor-availability
Updated sensor availability detection
2023-08-03 16:16:21 +02:00
Patrick762 80b03fc1d9 Merge branch 'sensor-availability' of https://github.com/Patrick762/hassio-bluetti-bt into sensor-availability 2023-08-03 16:11:26 +02:00
Patrick762 92c130b2d6 updated sensor availability detection 2023-08-03 16:10:30 +02:00
Patrick762 f8e382347e Merge pull request #4 from Patrick762/device-support
More EP600 fields
2023-08-03 16:08:51 +02:00
Patrick762 bab008b89c set logging level of parsed data to debug 2023-08-03 16:06:43 +02:00
Patrick762 811501b382 added adl400 voltage fields 2023-08-03 16:04:23 +02:00
Patrick762 90f66c8548 added dummy device to entities 2023-08-03 14:58:46 +02:00
Patrick762 924eaa90b6 Merge pull request #3 from Patrick762/device-support
Added more fields for ep600
2023-08-02 21:23:00 +02:00
Patrick762 4f95fed85a added more fields for ep600 2023-08-02 21:07:24 +02:00
Patrick762 332cf6dc28 Merge pull request #2 from Patrick762/device-support
Support for more fields
2023-08-01 21:23:38 +02:00
Patrick762 cc7891ed6a added more fields 2023-08-01 21:20:02 +02:00
Patrick762 12cd2719d2 added dummy device class to append new fields 2023-08-01 20:37:52 +02:00
Patrick762 e1d4446fb8 updated sensor availability detection 2023-08-01 19:46:48 +02:00
Patrick762 b7c705f224 updated sensor data handling 2023-07-31 20:32:59 +02:00
Patrick762 a71f419676 separated sensors and binary_sensors 2023-07-31 19:24:56 +02:00
Patrick762 7af1fb02c6 separated coordinator and sensor 2023-07-31 19:01:56 +02:00
16 changed files with 1554 additions and 194 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}
+12 -3
View File
@@ -1,13 +1,18 @@
# hassio-bluetti-bt # hassio-bluetti-bt
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
[![Validate with hassfest](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/hassfest_validation.yml/badge.svg)](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/hassfest_validation.yml) [![Validate with hassfest](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/hassfest_validation.yml/badge.svg)](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/hassfest_validation.yml)
[![HACS Action](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/HACS.yml/badge.svg)](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/HACS.yml) [![HACS Action](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/HACS.yml/badge.svg)](https://github.com/Patrick762/hassio-bluetti-bt/actions/workflows/HACS.yml)
Bluetti Integration for Home Assistant Bluetti Integration for Home Assistant
Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt). Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt).
## Installation
To install this integration, you first need [HACS](https://hacs.xyz/) installed.
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: ### Supported devices:
(based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices)) (based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices))
@@ -21,4 +26,8 @@ Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt).
- EP600 (tested) - EP600 (tested)
### Available sensors: ### 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)) 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:
If enabled in the Integration options (you need to reload the integration if you change this option):
AC and DC outputs
+38 -4
View File
@@ -3,23 +3,42 @@
from __future__ import annotations from __future__ import annotations
import re import re
import logging
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_TYPE, Platform from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, MANUFACTURER from .const import (
CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN,
CONF_POLLING_INTERVAL,
CONF_POLLING_TIMEOUT,
CONF_USE_CONTROLS,
DATA_COORDINATOR,
DATA_POLLING_RUNNING,
DOMAIN,
MANUFACTURER,
)
from .coordinator import PollingCoordinator
PLATFORMS: [Platform] = [Platform.SENSOR] PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_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."""
address = entry.data.get(CONF_ADDRESS) address = entry.data.get(CONF_ADDRESS)
device_name = entry.data.get(CONF_NAME)
use_controls = entry.data.get(CONF_USE_CONTROLS)
polling_interval = entry.data.get(CONF_POLLING_INTERVAL, 20)
persistent_conn = entry.data.get(CONF_PERSISTENT_CONN, False)
polling_timeout = entry.data.get(CONF_POLLING_TIMEOUT, 45)
max_retries = entry.data.get(CONF_MAX_RETRIES, 5)
if address is None: if address is None:
return False return False
@@ -27,8 +46,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not bluetooth.async_address_present(hass, address): if not bluetooth.async_address_present(hass, address):
raise ConfigEntryNotReady("Bluetti device not present") raise ConfigEntryNotReady("Bluetti device not present")
# Create data structure
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault(entry.entry_id, {})
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False)
# Create coordinator for polling
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)
platforms: list = PLATFORMS
if use_controls is True:
_LOGGER.warning("You are using controls with this integration at your own risk!")
platforms.append(Platform.SWITCH)
# Setup platforms # Setup platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, platforms)
return True return True
@@ -0,0 +1,135 @@
"""Bluetti BT sensors."""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import (
CONF_ADDRESS,
CONF_NAME,
EntityCategory,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
)
from bluetti_mqtt.bluetooth import build_device
from bluetti_mqtt.mqtt_client import (
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS
from .coordinator import PollingCoordinator, DummyDevice
from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Setup binary_sensor entities."""
device_name = entry.data.get(CONF_NAME)
address = entry.data.get(CONF_ADDRESS)
if address is None:
_LOGGER.error("Device has no address")
# Generate device info
_LOGGER.info("Creating binary_sensors for device with address %s", address)
device_info = dev_info(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)
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.setter is True:
category = EntityCategory.DIAGNOSTIC
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,
)
)
async_add_entities(sensors_to_add)
class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Bluetti universal binary sensor."""
def __init__(
self,
coordinator: PollingCoordinator,
device_info: DeviceInfo,
address,
response_key: str,
name: str,
category: EntityCategory | None = None,
):
"""Init battery entity."""
super().__init__(coordinator)
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_available = False
self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category
@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:
return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
if not isinstance(self.coordinator.data, dict):
_LOGGER.debug(
"Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id)
)
self._attr_available = False
return
response_data = self.coordinator.data.get(self._response_key)
if response_data is None:
self._attr_available = False
return
if not isinstance(response_data, bool):
_LOGGER.warning(
"Invalid response data type from coordinator (binary_sensor.%s): %s",
unique_id_loggable(self._attr_unique_id),
response_data,
)
self._attr_available = False
return
self._attr_available = True
self._attr_is_on = self.coordinator.data[self._response_key] is True
self.async_write_ha_state()
+99 -1
View File
@@ -14,9 +14,17 @@ from homeassistant.components.bluetooth import (
) )
from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from . import get_type_by_bt_name from . import get_type_by_bt_name
from .const import DOMAIN from .const import (
CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN,
CONF_POLLING_INTERVAL,
CONF_POLLING_TIMEOUT,
CONF_USE_CONTROLS,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -86,3 +94,93 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user", step_id="user",
data_schema=data_schema, data_schema=data_schema,
) )
@staticmethod
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
# Validate update interval
if user_input[CONF_POLLING_INTERVAL] < 5:
return self.async_abort(reason="invalid_interval")
# Validate update timeout
if user_input[CONF_POLLING_TIMEOUT] < 1:
return self.async_abort(reason="invalid_timeout")
# Validate max retries
if user_input[CONF_MAX_RETRIES] < 1:
return self.async_abort(reason="invalid_retries")
changed = self.hass.config_entries.async_update_entry(
self.config_entry,
data={
**self.config_entry.data,
**{
CONF_USE_CONTROLS: user_input[CONF_USE_CONTROLS],
CONF_PERSISTENT_CONN: user_input[CONF_PERSISTENT_CONN],
CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL],
CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT],
CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES],
},
},
)
if changed is False:
_LOGGER.error(
"Method OptionsFlowHandler.async_step_init: Config entry %s has not been changed",
self.config_entry.entry_id,
)
return self.async_create_entry(
title="",
data={
CONF_USE_CONTROLS: user_input[CONF_USE_CONTROLS],
CONF_PERSISTENT_CONN: user_input[CONF_PERSISTENT_CONN],
CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL],
CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT],
CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES],
},
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_USE_CONTROLS,
default=self.config_entry.data.get(CONF_USE_CONTROLS, False),
): selector.BooleanSelector(),
vol.Required(
CONF_PERSISTENT_CONN,
default=self.config_entry.data.get(CONF_PERSISTENT_CONN, False),
): selector.BooleanSelector(),
vol.Required(
CONF_POLLING_INTERVAL,
default=self.config_entry.data.get(CONF_POLLING_INTERVAL, 20),
): int,
vol.Required(
CONF_POLLING_TIMEOUT,
default=self.config_entry.data.get(CONF_POLLING_TIMEOUT, 45),
): int,
vol.Required(
CONF_MAX_RETRIES,
default=self.config_entry.data.get(CONF_MAX_RETRIES, 5),
): int,
}
),
)
+531 -2
View File
@@ -1,7 +1,536 @@
"""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"
API_RESPONSE_BATTERY_RANGE_START = "battery_range_start" CONF_OPTIONS = "options"
API_RESPONSE_BATTERY_RANGE_END = "battery_range_end" CONF_USE_CONTROLS = "use_controls"
CONF_PERSISTENT_CONN = "persistent_conn"
CONF_POLLING_INTERVAL = "polling_interval"
CONF_POLLING_TIMEOUT = "polling_timeout"
CONF_MAX_RETRIES = "max_retries"
DATA_COORDINATOR = "coordinator"
DATA_POLLING_RUNNING = "polling_running"
CONTROL_FIELDS = [
"ac_output_on",
"dc_output_on",
]
DIAGNOSTIC_FIELDS = [
"max_ac_input_power",
"max_ac_input_current",
"max_ac_output_power",
"max_ac_output_current",
"bcu_version",
"safety_module_version",
"high_voltage_module_version",
]
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",
},
),
}
+343
View File
@@ -0,0 +1,343 @@
"""Coordinator for Bluetti integration."""
from __future__ import annotations
from typing import List, cast
import asyncio
from datetime import timedelta
import logging
import async_timeout
from bleak import BleakClient, BleakError
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
from bluetti_mqtt.bluetooth.client import BluetoothClient
from bluetti_mqtt.bluetooth import (
BadConnectionError,
ModbusError,
ParseError,
build_device,
)
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.core.commands import ReadHoldingRegisters
from .const import DATA_POLLING_RUNNING, DOMAIN
from .utils import mac_loggable
_LOGGER = logging.getLogger(__name__)
class DummyDevice(BluettiDevice):
"""Dummy device used to add more fields to existing devices.
Changes made here are only temporary and should also be
contributed to https://github.com/warhammerkid/bluetti_mqtt
"""
def __init__(self, device: BluettiDevice):
"""Init dummy device with real device."""
self.struct = device.struct
if device.type == "EP600":
# DC Solar Input (copied from PR https://github.com/warhammerkid/bluetti_mqtt/pull/87 by KM011092)
self.struct.add_uint_field('pv_input_power1', 1212) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage1', 1213) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_current1', 1214) # MPP 1 in
self.struct.add_uint_field('pv_input_power2', 1220) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage2', 1221) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_current2', 1222) # MPP 2 in
# ADL400 Smart Meter for AC Solar
self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228)
self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236)
self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1)
# Grid Input
self.struct.add_decimal_field("grid_input_frequency", 1300, 1)
self.struct.add_uint_field("grid_input_power_phase1", 1313)
self.struct.add_uint_field("grid_input_power_phase2", 1319)
self.struct.add_uint_field("grid_input_power_phase3", 1325)
self.struct.add_decimal_field("grid_input_voltage_phase1", 1314, 1)
self.struct.add_decimal_field("grid_input_voltage_phase2", 1320, 1)
self.struct.add_decimal_field("grid_input_voltage_phase3", 1326, 1)
self.struct.add_decimal_field("grid_input_current_phase1", 1315, 1)
self.struct.add_decimal_field("grid_input_current_phase2", 1321, 1)
self.struct.add_decimal_field("grid_input_current_phase3", 1327, 1)
# EP600 AC Output
self.struct.add_decimal_field("ac_output_frequency", 1500, 1)
self.struct.add_uint_field("ac_output_power_phase1", 1510)
self.struct.add_uint_field("ac_output_power_phase2", 1517)
self.struct.add_uint_field("ac_output_power_phase3", 1524)
self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1)
self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1)
self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1)
self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1)
self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1)
self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1)
super().__init__(device.address, device.type, device.sn)
self._parent = device
@property
def pack_num_max(self):
return self._parent.pack_num_max
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
if self.type == "EP600":
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(1228, 19),
ReadHoldingRegisters(1300, 28),
ReadHoldingRegisters(1500, 27),
ReadHoldingRegisters(2022, 6),
ReadHoldingRegisters(2213, 4),
ReadHoldingRegisters(1212, 11),
# Battery
ReadHoldingRegisters(6101, 7),
ReadHoldingRegisters(6175, 11),
]
return self._parent.polling_commands
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
if self.type == "EP600":
return []
return self._parent.pack_polling_commands
@property
def logging_commands(self) -> List[ReadHoldingRegisters]:
return self._parent.logging_commands
@property
def pack_logging_commands(self) -> List[ReadHoldingRegisters]:
return self._parent.pack_logging_commands
@property
def writable_ranges(self) -> List[range]:
return self._parent.writable_ranges
class PollingCoordinator(DataUpdateCoordinator):
"""Polling coordinator."""
def __init__(
self,
hass: HomeAssistant,
address: str,
device_name: str,
polling_interval: int,
persistent_conn: bool,
polling_timeout: int,
max_retries: int,
):
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="Bluetti polling coordinator",
update_interval=timedelta(seconds=polling_interval),
)
self._address = address
self.has_notifier = False
self.notify_future = None
self.current_command = None
self.notify_response = bytearray()
bluetti_device = build_device(address, device_name)
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
# Add or modify device fields
self.bluetti_device = DummyDevice(bluetti_device)
# Create client
self.logger.debug("Creating client")
device = bluetooth.async_ble_device_from_address(hass, address)
if device is None:
self.logger.error("Device %s not available", mac_loggable(address))
return None
self.client = BleakClient(device)
# polling mutex to guard against switches
self.polling_lock = asyncio.Lock()
async def _async_update_data(self):
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = True
self.logger.debug("Polling data")
if self.bluetti_device is None:
self.logger.error("Device type for %s not found", mac_loggable(self._address))
return None
parsed_data: dict = {}
async with self.polling_lock:
try:
async with async_timeout.timeout(self.polling_timeout):
# Reconnect if not connected
for attempt in range(1, self.max_retries + 1):
try:
if not self.client.is_connected:
await self.client.connect()
break
except Exception as e:
if attempt == self.max_retries or attempt == 1:
raise # pass exception on max_retries attempt
else:
self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...")
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
BluetoothClient.NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
for command in self.bluetti_device.polling_commands:
try:
body = command.parse_response(
await self.async_send_command(command)
)
self.logger.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
self.logger.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
self.logger.warning("Got a parse exception...")
if len(self.bluetti_device.pack_polling_commands) > 0:
self.logger.debug("Polling battery packs")
# pack polling
for pack in range (1, self.bluetti_device.pack_num_max + 1):
# Set current pack number
await self.async_send_command(
self.bluetti_device.build_setter_command('pack_num', pack)
)
for command in self.bluetti_device.pack_polling_commands:
# Request & parse result for each pack
try:
body = command.parse_response(
await self.async_send_command(command)
)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
self.logger.debug("Parsed data: %s", parsed)
pack_number = parsed.get('pack_num')
if not isinstance(pack_number, int) or pack_number != pack:
self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", pack_number, pack)
continue
for key, value in parsed.items():
parsed_data.update({key+str(pack):value})
except ParseError:
self.logger.warning("Got a parse exception...")
except TimeoutError:
self.logger.warning("Polling timed out for device %s", mac_loggable(self._address))
return None
except BleakError as err:
self.logger.warning("Bleak error: %s", err)
return None
finally:
# Disconnect if connection not persistant
if not self.persistent_conn:
if self.has_notifier:
await self.client.stop_notify(BluetoothClient.NOTIFY_UUID)
self.has_notifier = False
await self.client.disconnect()
self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False
# Pass data back to sensors
return parsed_data
async def async_send_command(self, command: ReadHoldingRegisters) -> bytes:
"""Send command and return response"""
try:
# Prepare to make request
self.current_command = command
self.notify_future = self.hass.loop.create_future()
self.notify_response = bytearray()
# Make request
self.logger.debug("Requesting %s", command)
await self.client.write_gatt_char(
BluetoothClient.WRITE_UUID, bytes(command)
)
# Wait for response
res = await asyncio.wait_for(
self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT
)
# Process data
self.logger.debug("Got %s bytes", len(res))
return cast(bytes, res)
except TimeoutError:
self.logger.debug(
"Polling timed out (address: %s)", mac_loggable(self._address)
)
except ModbusError as err:
self.logger.warning(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
self.logger.warning(
"Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err
)
# caught an exception, return empty bytes object
return bytes()
def _notification_handler(self, _sender: int, data: bytearray):
"""Handle bt data."""
# Ignore notifications we don't expect
if not self.notify_future or self.notify_future.done():
_LOGGER.warning("Unexpected notification")
return
# If something went wrong, we might get weird data.
if data == b"AT+NAME?\r" or data == b"AT+ADV?\r":
err = BadConnectionError("Got AT+ notification")
self.notify_future.set_exception(err)
return
# Save data
self.notify_response.extend(data)
if len(self.notify_response) == self.current_command.response_size():
if self.current_command.is_valid_response(self.notify_response):
self.notify_future.set_result(self.notify_response)
else:
self.notify_future.set_exception(ParseError("Failed checksum"))
elif self.current_command.is_exception_response(self.notify_response):
# We got a MODBUS command exception
msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}"
self.notify_future.set_exception(ModbusError(msg))
+1 -1
View File
@@ -18,5 +18,5 @@
"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": ["bluetti_mqtt==0.15.0"],
"version": "0.0.2" "version": "0.0.11"
} }
+110 -182
View File
@@ -1,16 +1,11 @@
"""Bluetti BT sensors.""" """Bluetti BT sensors."""
from __future__ import annotations from __future__ import annotations
from enum import Enum
import asyncio
from datetime import timedelta
import logging import logging
from typing import cast from decimal import Decimal
import async_timeout
from bleak import BleakClient, BleakError
from homeassistant.components import bluetooth
from homeassistant.components.sensor import SensorEntity, CONF_STATE_CLASS from homeassistant.components.sensor import SensorEntity, CONF_STATE_CLASS
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -25,23 +20,20 @@ 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,
DataUpdateCoordinator,
) )
from bluetti_mqtt.bluetooth.client import BluetoothClient from bluetti_mqtt.bluetooth import build_device
from bluetti_mqtt.bluetooth import ( from bluetti_mqtt.mqtt_client import (
BadConnectionError, NORMAL_DEVICE_FIELDS,
ModbusError, DC_INPUT_FIELDS,
ParseError, MqttFieldType,
build_device, battery_pack_fields,
) )
from bluetti_mqtt.mqtt_client import NORMAL_DEVICE_FIELDS
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import ( from .const import DATA_COORDINATOR, DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS
API_RESPONSE_BATTERY_RANGE_START, from .coordinator import PollingCoordinator, DummyDevice
API_RESPONSE_BATTERY_RANGE_END, from .utils import unique_id_loggable
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -56,171 +48,61 @@ async def async_setup_entry(
if address is None: if address is None:
_LOGGER.error("Device has no address") _LOGGER.error("Device has no address")
# Create coordinator for polling
coordinator = PollingCoordinator(hass, address, device_name)
await coordinator.async_config_entry_first_refresh()
# Generate device info # Generate device info
_LOGGER.info("Creating sensors for device with address %s", address) _LOGGER.info("Creating sensors for device with address %s", address)
device_info = dev_info(entry) device_info = dev_info(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 = []
for field_key, field_config in NORMAL_DEVICE_FIELDS.items(): all_fields = NORMAL_DEVICE_FIELDS
if bluetti_device.has_field(field_key): all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
if len(bluetti_device.pack_polling_commands) > 0:
# add pack fields for device
_LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max)
for pack in range (1, bluetti_device.pack_num_max + 1):
for name, field in battery_pack_fields(pack).items():
all_fields.update({name+str(pack): field})
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key) or field_config.id_override is not None:
category = None category = None
if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS:
if field_key in (
API_RESPONSE_BATTERY_RANGE_START,
API_RESPONSE_BATTERY_RANGE_END,
):
category = EntityCategory.DIAGNOSTIC category = EntityCategory.DIAGNOSTIC
if field_config.type == MqttFieldType.NUMERIC:
sensors_to_add.append( sensors_to_add.append(
BluettiSensor( BluettiSensor(
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.home_assistant_extra.get(CONF_NAME, ""),
field_config.home_assistant_extra.get(CONF_UNIT_OF_MEASUREMENT, ""), field_config.home_assistant_extra.get(CONF_UNIT_OF_MEASUREMENT),
field_config.home_assistant_extra.get(CONF_DEVICE_CLASS, ""), field_config.home_assistant_extra.get(CONF_DEVICE_CLASS),
field_config.home_assistant_extra.get(CONF_STATE_CLASS, ""), field_config.home_assistant_extra.get(CONF_STATE_CLASS),
category, category=category,
)
)
elif field_config.type == MqttFieldType.ENUM:
sensors_to_add.append(
BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
options=field_config.home_assistant_extra.get(CONF_OPTIONS),
category=category,
)
) )
)
async_add_entities(sensors_to_add) async_add_entities(sensors_to_add)
class PollingCoordinator(DataUpdateCoordinator):
"""Polling coordinator."""
def __init__(self, hass: HomeAssistant, address, device_name: str):
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="Bluetti polling coordinator",
update_interval=timedelta(seconds=20),
)
self._address = address
self.notify_future = None
self.current_command = None
self.notify_response = bytearray()
self.bluetti_device = build_device(address, device_name)
async def _async_update_data(self):
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
self.logger.debug("Polling data")
device = bluetooth.async_ble_device_from_address(self.hass, self._address)
if device is None:
self.logger.error("Device not available")
return None
if self.bluetti_device is None:
self.logger.error("Device type not found")
return None
# Polling
client = BleakClient(device)
parsed_data: dict = {}
try:
async with async_timeout.timeout(15):
await client.connect()
await client.start_notify(
BluetoothClient.NOTIFY_UUID, self._notification_handler
)
for command in self.bluetti_device.polling_commands:
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 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))
response = cast(bytes, res)
body = command.parse_response(response)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
self.logger.warning("Parsed data: %s", parsed)
parsed_data.update(parsed)
except TimeoutError:
self.logger.error("Polling timed out")
except ParseError:
self.logger.debug("Got a parse exception...")
except ModbusError as err:
self.logger.debug(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
self.logger.debug("Needed to disconnect due to error: %s", err)
except TimeoutError:
self.logger.error("Polling timed out")
return None
except BleakError as err:
self.logger.error("Bleak error: %s", err)
return None
finally:
await client.disconnect()
# Pass data back to sensors
return parsed_data
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():
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))
class BluettiSensor(CoordinatorEntity, SensorEntity): class BluettiSensor(CoordinatorEntity, SensorEntity):
"""Bluetti universal sensor.""" """Bluetti universal sensor."""
@@ -231,10 +113,11 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
address, address,
response_key: str, response_key: str,
name: str, name: str,
unit_of_measurement: str, unit_of_measurement: str | None = None,
device_class: str, device_class: str | None = None,
state_class: str, state_class: str | None = None,
category: EntityCategory | None = None, category: EntityCategory | None = None,
options: list[str] | None = None,
): ):
"""Init battery entity.""" """Init battery entity."""
super().__init__(coordinator) super().__init__(coordinator)
@@ -245,24 +128,69 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_name = e_name
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
self._attr_device_class = device_class self._attr_device_class = device_class
self._attr_state_class = state_class self._attr_state_class = state_class
self._attr_entity_category = category self._attr_entity_category = category
self._options = options
@property
def available(self) -> bool:
if self._address is None:
return False
return bluetooth.async_address_present(self.hass, self._address)
@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."""
_LOGGER.debug("Updating state of %s", self._attr_unique_id)
if not isinstance(self.coordinator.data, dict): if self.coordinator.persistent_conn and not self.coordinator.client.is_connected:
_LOGGER.error("Invalid data from coordinator")
return return
self._attr_native_value = self.coordinator.data[self._response_key]
if self.coordinator.data is None:
_LOGGER.warning(
"Data from coordinator is None. Skipping update for %s",
unique_id_loggable(self._attr_unique_id)
)
return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
if not isinstance(self.coordinator.data, dict):
_LOGGER.warning(
"Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id)
)
self._attr_available = False
return
response_data = self.coordinator.data.get(self._response_key)
if response_data is None:
_LOGGER.warning("No data for available for (%s)", self._response_key)
self._attr_available = False
return
if (
not isinstance(response_data, int)
and not isinstance(response_data, float)
and not isinstance(response_data, complex)
and not isinstance(response_data, Decimal)
and not isinstance(response_data, Enum)
):
_LOGGER.warning(
"Invalid response data type from coordinator (sensor.%s): %s has type %s",
unique_id_loggable(self._attr_unique_id),
response_data,
type(response_data),
)
self._attr_available = False
return
self._attr_available = True
# Different for enum and numeric
if (
self._options is not None
and isinstance(response_data, Enum)
and response_data.value < len(self._options)
):
# Enum
self._attr_native_value = self._options[response_data.value]
else:
# Numeric
self._attr_native_value = response_data
self.async_write_ha_state() self.async_write_ha_state()
+190
View File
@@ -0,0 +1,190 @@
"""Bluetti BT switches."""
from __future__ import annotations
import asyncio
import logging
import async_timeout
from bleak import BleakError
from homeassistant.components import bluetooth
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import (
CONF_ADDRESS,
CONF_NAME,
EntityCategory,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
)
from bluetti_mqtt.bluetooth import build_device
from bluetti_mqtt.bluetooth.client import BluetoothClient
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.mqtt_client import (
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from . import device_info as dev_info, get_unique_id
from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS
from .coordinator import PollingCoordinator, DummyDevice
from .utils import mac_loggable, unique_id_loggable
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Setup switch entities."""
device_name = entry.data.get(CONF_NAME)
address = entry.data.get(CONF_ADDRESS)
if address is None:
_LOGGER.error("Device has no address")
# Generate device info
_LOGGER.info("Creating switches for device with address %s", address)
device_info = dev_info(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)
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key):
if field_config.type == MqttFieldType.BOOL:
if field_config.setter is True and field_key in CONTROL_FIELDS:
sensors_to_add.append(
BluettiSwitch(
bluetti_device,
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info,
address,
field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""),
entry.entry_id
)
)
async_add_entities(sensors_to_add)
class BluettiSwitch(CoordinatorEntity, SwitchEntity):
"""Bluetti universal switch."""
def __init__(
self,
bluetti_device: BluettiDevice,
coordinator: PollingCoordinator,
device_info: DeviceInfo,
address,
response_key: str,
name: str,
entry_id: str,
category: EntityCategory | None = None,
):
"""Init entity."""
super().__init__(coordinator)
self._bluetti_device = bluetti_device
self._coordinator = coordinator
self._client = coordinator.client
self._polling_lock = coordinator.polling_lock
e_name = f"{device_info.get('name')} {name}"
self._address = address
self._response_key = response_key
self._entry_id = entry_id
self._attr_device_info = device_info
self._attr_name = e_name
self._attr_available = False
self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category
self._attr_device_class = SwitchDeviceClass.OUTLET
@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:
return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
if not isinstance(self.coordinator.data, dict):
_LOGGER.error(
"Invalid data from coordinator (switch.%s)", unique_id_loggable(self._attr_unique_id)
)
self._attr_available = False
return
response_data = self.coordinator.data.get(self._response_key)
if response_data is None:
self._attr_available = False
return
if not isinstance(response_data, bool):
_LOGGER.warning(
"Invalid response data type from coordinator (switch.%s): %s",
unique_id_loggable(self._attr_unique_id),
response_data,
)
self._attr_available = False
return
self._attr_available = True
self._attr_is_on = self.coordinator.data[self._response_key] is True
self.async_write_ha_state()
async def async_turn_on(self, **kwargs):
"""Turn the entity on."""
_LOGGER.debug("Turn on %s on %s", self._response_key, mac_loggable(self._address))
await self.write_to_device(True)
async def async_turn_off(self, **kwargs):
"""Turn the entity off."""
_LOGGER.debug("Turn off %s on %s", self._response_key, mac_loggable(self._address))
await self.write_to_device(False)
async def write_to_device(self, state: bool):
"""Write to device."""
command = self._bluetti_device.build_setter_command(self._response_key, state)
async with self._polling_lock:
try:
async with async_timeout.timeout(15):
if not self._client.is_connected:
await self._client.connect()
# Send command
_LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state)
await self._client.write_gatt_char(
BluetoothClient.WRITE_UUID, bytes(command)
)
# Wait until device has changed value, otherwise reading register might reset it
await asyncio.sleep(5)
except TimeoutError:
_LOGGER.error("Timed out for device %s", mac_loggable(self._address))
return None
except BleakError as err:
_LOGGER.error("Bleak error: %s", err)
return None
finally:
# Disconnect if connection not persistant
if not self._coordinator.persistent_conn:
await self._client.disconnect()
await self.coordinator.async_request_refresh()
@@ -0,0 +1,20 @@
{
"options": {
"step": {
"init": {
"data": {
"use_controls": "Steuerung aktivieren (auf eigenes Risiko)",
"persistent_conn": "Dauerhafte Verbindung (Neustart erforderlich)",
"polling_interval": "Datenabruf-Intervall in Sekunden (Neustart erforderlich)",
"polling_timeout": "Datenabruf-Timeout in Sekunden (Neustart erforderlich)",
"max_retries": "Maximale Verbindungsversuche (Neustart erforderlich)"
}
}
},
"abort": {
"invalid_interval": "Ungültiger Datenabruf-Intervall. Verwende 5 Sekunden oder mehr",
"invalid_timeout": "Ungültiger Datenabruf-Timeout. Verwende 1 Sekunde oder mehr",
"invalid_retries": "Ungültige maximale Verbindungsversuche. Verwende 1 oder mehr"
}
}
}
@@ -0,0 +1,20 @@
{
"options": {
"step": {
"init": {
"data": {
"use_controls": "Use controls (use at own risk)",
"persistent_conn": "Persistent connection (restart required)",
"polling_interval": "Polling interval in seconds (restart required)",
"polling_timeout": "Polling timeout in seconds (restart required)",
"max_retries": "Maximum amount of connection retries (restart required)"
}
}
},
"abort": {
"invalid_interval": "Invalid polling interval. Use 5 seconds or more",
"invalid_timeout": "Invalid polling timeout. Use 1 second or more",
"invalid_retries": "Invalid max retries. Use 1 or more"
}
}
}
@@ -0,0 +1,16 @@
{
"options": {
"step": {
"init": {
"data": {
"use_controls": "Usar os comandos",
"persistent_conn": "Ligação presistente (necessario reninciar)",
"polling_interval": "Intervalo deatualização (necessario reninciar)"
}
}
},
"abort": {
"invalid_interval": "Valor invalido . Tem de ser no minimo 5 segundos"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
"""Tools."""
def mac_loggable(mac: str) -> str:
"""Remove parts of the mac address for logging."""
splitted = mac.split(":")
return "XX:XX:XX:XX:XX:" + splitted[-1]
def unique_id_loggable(unique_id: str) -> str:
"""Remove parts of the unique id for logging."""
splitted = unique_id.split("_", maxsplit=1)
serial = splitted[0][:6]
return serial + "XXXXXXXXXX" + splitted[1]
-1
View File
@@ -1 +0,0 @@
robocopy .\custom_components\bluetti_bt T:\custom_components\bluetti_bt /E
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# This script is only for development purposes
sudo cp -r ./custom_components/* /var/ha_config/custom_components/