Compare commits

...
155 Commits
Author SHA1 Message Date
Patrick762 f5430216a8 Merge pull request #103 from Patrick762/values-fix-1
Values fix AC300 and AC500
2024-05-30 20:25:02 +02:00
Patrick762 591c86baf5 fix internal_dc_input_voltage for ac300 and ac500 2024-05-30 20:22:43 +02:00
Patrick762 97154bb027 increase version number 2024-05-30 19:57:02 +02:00
Patrick762 b2672ec302 fix availability states 2024-05-30 19:56:23 +02:00
Patrick762 f5b8e09fb4 prevent binary_sensor if controls enabled 2024-05-30 15:03:47 +02:00
Patrick762 c8d75d1899 Merge pull request #102 from Patrick762/bug-report-template
updated bug report template
2024-05-30 12:30:47 +02:00
Patrick762 efaa9d0fdb updated bug report template 2024-05-30 12:29:53 +02:00
Patrick762 9315650ddc Merge pull request #88 from Patrick762/pack-polling
Check pack_num only once
2024-05-30 12:27:51 +02:00
Patrick762 9df2073585 Merge pull request #96 from KM011092/dev
Additional EP600 values
2024-04-28 17:33:31 +02:00
KM011092 2c6df77078 Deleted .idea directory 2024-04-28 17:31:03 +02:00
KM011092 e584d32037 Additional EP600 values
Added Consumption and Grid, Consumption and Solar in Totals
2024-04-19 11:41:43 +02:00
Patrick762 5ac8d8d7ae only check pack_num once 2024-04-01 14:31:03 +02:00
Patrick762 0cd5ae10fb Merge pull request #87 from Patrick762/pack-polling-fix
Fix writeable_ranges for pack_polling
2024-04-01 14:13:48 +02:00
Patrick762 a93b80796a increase version number 2024-04-01 14:11:24 +02:00
Patrick762 a7967e74dd fix writeable_ranges for ac500, ac300, ep500 and ep500p 2024-04-01 14:08:42 +02:00
Patrick762 5bd98d6de3 Merge pull request #86 from Patrick762/ac200l
Ac200l support and manifest fix
2024-04-01 12:41:23 +02:00
Patrick762 2a4c3e943c updated manifest version 2024-04-01 12:37:48 +02:00
Patrick762 dcfebb0f84 added untested AC200L support based on AC200M 2024-04-01 12:37:22 +02:00
Patrick762 44dbd2b965 Merge pull request #85 from Patrick762/manifest-fix
fix dependency for bt adapters
2024-04-01 12:29:54 +02:00
Patrick762 89928e2d9c fix dependency for bt adapters 2024-04-01 12:29:01 +02:00
Patrick762 dc3aaf92e1 Merge pull request #77 from Patrick762/register-readers-fix
Bugfixes
2024-03-16 17:53:19 +01:00
Patrick762 95b1dafeb1 increase version number 2024-03-16 17:51:46 +01:00
Patrick762 15a890fb7d fix enum entities 2024-03-16 17:49:17 +01:00
Patrick762 542f244a28 removed pack polling from base device 2024-03-16 17:27:28 +01:00
Patrick762 eb701de865 fixed some polling addresses 2024-03-16 16:57:25 +01:00
Patrick762 f8ed8141ac Merge pull request #71 from Patrick762/bugfixes
Bugfixes and improvements
2024-03-12 21:03:07 +01:00
Patrick762 bf86fe172b update version number 2024-03-12 20:56:13 +01:00
Patrick762 babdd1755e fix dc switch, added some switches for eb3a 2024-03-12 20:55:09 +01:00
Patrick762 7596f8cee1 fix ac_output_on switches 2024-03-12 20:25:24 +01:00
Patrick762 98d2a0b73e Merge pull request #72 from Patrick762/debug-logging
Debug logging
2024-03-12 20:20:37 +01:00
Patrick762 c499fbcd9b fix writable_ranges 2024-03-12 20:13:20 +01:00
Patrick762 ec996d5728 added debug logging to init 2024-03-12 20:12:39 +01:00
Patrick762 ec657ad59b Merge pull request #66 from Patrick762/ac180-ac180p
AC180 AC180P
2024-02-25 16:48:03 +01:00
Patrick762 df8375016c prepare next beta release 2024-02-25 16:46:14 +01:00
Patrick762 8901c11afc added unittest 2024-02-25 16:41:47 +01:00
Patrick762 da5443172b added ac180 and ac180p 2024-02-25 16:41:29 +01:00
Patrick762 85e50498f6 Merge pull request #64 from Patrick762/ep800
EP800
2024-02-25 15:40:30 +01:00
Patrick762 5fa1c8631f next beta release 2024-02-25 15:37:48 +01:00
Patrick762 9cba466228 basic ep800 support 2024-02-25 15:37:12 +01:00
Patrick762 cc42617bdb Merge pull request #63 from Patrick762/values-fix
Values fix
2024-02-25 15:08:50 +01:00
Patrick762 4d27f108fa fixed negative ac and dc solar values 2024-02-25 15:04:18 +01:00
Patrick762 6412630c85 added new int field 2024-02-25 15:03:27 +01:00
Patrick762 3f51dd829e prepare beta version 0.0.17 2024-02-18 17:02:24 +01:00
Patrick762 64fbcbd08b Merge pull request #52 from Patrick762/integrated-lib
Switching to integrated bluetti library
2024-02-18 16:55:34 +01:00
Patrick762 c59881de7f fix switch coordinator references 2024-02-18 16:52:23 +01:00
Patrick762 842f133cd4 code cleanup 2024-02-18 16:49:27 +01:00
Patrick762 a50deaae37 added device type detection to config_flow 2024-02-18 16:37:37 +01:00
Patrick762 9b7fd82591 extracted DeviceReader class from coordinator 2024-02-18 16:32:05 +01:00
Patrick762 e40e056e56 added more diagnostic fields 2024-02-18 14:46:52 +01:00
Patrick762 38e1fa4ecd added ReadHoldingRegisters for ep600 2024-02-18 14:44:44 +01:00
Patrick762 564a971d7c move get_type_by_bt_name into lib 2024-02-18 14:35:16 +01:00
Patrick762 caac9330b4 field_attributes cleanup 2024-02-18 14:26:55 +01:00
Patrick762 d8999c6470 fixed field_attributes dataclasses 2024-02-18 14:21:47 +01:00
Patrick762 c45994a9a6 replaced bluetti_mqtt imports with local lib 2024-02-18 13:54:40 +01:00
Patrick762 18825f9062 removed old ep760 class 2024-02-18 13:36:54 +01:00
Patrick762 8409221675 added ep760 2024-02-18 13:35:17 +01:00
Patrick762 2abcbbb392 added build_device() 2024-02-18 13:29:20 +01:00
Patrick762 71bf9b1952 removed bluetti_mqtt requirement 2024-02-18 13:19:02 +01:00
Patrick762 d8c61e1ebe added ep600 2024-02-17 21:23:08 +01:00
Patrick762 03b11a69d5 added ep500p 2024-02-17 20:46:20 +01:00
Patrick762 3a709f9bc5 added ep500 2024-02-17 20:43:34 +01:00
Patrick762 e395c400b5 added eb3a 2024-02-17 20:36:32 +01:00
Patrick762 3d65d8b00f added ac500 2024-02-17 20:24:44 +01:00
Patrick762 a183e8b02f make options optional 2024-02-17 20:19:19 +01:00
Patrick762 432d46d0f1 black code formatting 2024-02-17 20:14:12 +01:00
Patrick762 928ff2bf5d added ac300 2024-02-17 20:09:59 +01:00
Patrick762 d9d4860d44 added ac200m 2024-02-17 18:22:41 +01:00
Patrick762 5fcd57b517 added enums 2024-02-17 18:08:05 +01:00
Patrick762 4f92e9147e added pack polling registers to base devices 2024-02-17 18:07:51 +01:00
Patrick762 037ce83891 added pack selector 2024-02-17 18:00:59 +01:00
Patrick762 f7b4d8dc37 added ac60 2024-02-17 17:53:34 +01:00
Patrick762 01cb7f35a5 added pack fields to base devices 2024-02-17 17:52:08 +01:00
Patrick762 cac64b40bd added power_generation to base devices 2024-02-17 17:25:57 +01:00
Patrick762 4231b34bb8 added basic v1 and v2 protocol devices 2024-02-16 21:30:29 +01:00
Patrick762 f6f19b27e1 added local bluetti lib 2024-02-16 21:07:06 +01:00
Patrick762 0943ecf187 fix manifest version number 2024-02-10 14:20:37 +01:00
Patrick762 58f2ec50c4 added sleep() between pack pollings 2024-02-10 14:15:52 +01:00
Patrick762 ad536235bb removed device name from entity name 2024-02-10 13:59:01 +01:00
Patrick762 ba6611b084 removed duplicate re.sub() 2024-02-10 13:21:49 +01:00
Patrick762 3e4b8601e8 added dropdown to select device model 2024-02-10 13:19:16 +01:00
Patrick762 ebbcea3c4c fixed circular import 2024-02-05 19:10:11 +01:00
Patrick762 9ef9d38a6e renamed ep700 to ep760 2024-02-04 12:05:53 +01:00
Patrick762 463f02b09b Merge pull request #37 from Patrick762/ep700
Added EP700
2024-02-03 18:01:52 +01:00
Patrick762 8eb09a32f0 added ep700, replaced dummy device 2024-02-03 17:57:49 +01:00
Patrick762 e8b9db9a42 Delete old issue template 2024-01-09 20:57:42 +01:00
Patrick762 9c7e5968bb Update bug_report.yaml 2024-01-09 20:56:27 +01:00
Patrick762 0420511d90 Update and rename bug_report.md to bug_report_template.md 2024-01-09 20:53:24 +01:00
Patrick762 dcc6131d02 Create bug_report.yaml 2024-01-09 20:49:56 +01:00
Patrick762 11716084c9 Update issue templates 2024-01-09 20:39:17 +01:00
Patrick762 aa1c3ef425 Update issue templates 2024-01-09 20:35:49 +01:00
Patrick762 a8931e09c7 added disclaimer 2024-01-06 15:46:04 +01:00
Patrick762 66a7de9051 added has_entity_name for compatibility reasons 2024-01-06 15:39:08 +01:00
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
45 changed files with 2779 additions and 221 deletions

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["bug"]
assignees:
- octocat
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "An exception appeared"
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of our software are you running?
options:
- 0.0.11
- 0.0.10
- 0.0.9
default: 0
validations:
required: true
- type: textarea
id: device
attributes:
label: What device are you seeing the problem on?
validations:
required: true
- type: dropdown
id: bluetooth
attributes:
label: What bluetooth connection are you using?
multiple: false
options:
- USB dongle
- ESPHome bluetooth proxy
- Internal bluetooth adapter
validations:
required: true
- type: dropdown
id: options-changed
attributes:
label: Have you changed the integration settings?
multiple: false
options:
- No
- Yes (Please write down the changed settings in the next step)
validations:
required: true
- type: textarea
id: options-details
attributes:
label: Integration settings (if you changed them)
description: Please use one line per option
render: text
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
+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 }}
+17 -6
View File
@@ -1,17 +1,25 @@
# 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). ## 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.
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))
- AC60 - AC60
- AC180 (basic data)
- AC180P (basic data)
- AC200L (untested)
- AC200M - AC200M
- AC300 (tested) - AC300 (tested)
- AC500 - AC500
@@ -19,6 +27,9 @@ Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt).
- EP500 - EP500
- EP500P - EP500P
- EP600 (tested) - EP600 (tested)
- EP760 (basic data)
- EP800 (basic data)
### Available sensors: ### Available controls:
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)) If enabled in the Integration options (you need to reload the integration if you change this option):
AC and DC outputs
+39 -28
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import re import re
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
@@ -11,17 +13,35 @@ 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 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: 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)
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
@@ -29,16 +49,27 @@ 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 coordinator for polling
coordinator = PollingCoordinator(hass, address, device_name)
await coordinator.async_config_entry_first_refresh()
# Create data structure # Create data structure
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault(entry.entry_id, coordinator) hass.data[DOMAIN].setdefault(entry.entry_id, {})
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!")
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)
_LOGGER.debug("Setup done")
return True return True
@@ -59,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
+37 -19
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import logging import logging
from homeassistant.components import bluetooth
from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -19,12 +18,13 @@ 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 NORMAL_DEVICE_FIELDS, MqttFieldType from .bluetti_bt_lib.utils.device_builder import build_device
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import DOMAIN from .const import DATA_COORDINATOR, DOMAIN, CONF_USE_CONTROLS
from .coordinator import PollingCoordinator from .coordinator import PollingCoordinator
from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -32,35 +32,41 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Setup sensor entities.""" """Setup binary_sensor entities."""
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)
if address is None: if address is None:
_LOGGER.error("Device has no address") _LOGGER.error("Device has no address")
# Generate device info # Generate device info
_LOGGER.info("Creating sensors for device with address %s", address) _LOGGER.info("Creating binary_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)
sensors_to_add = [] sensors_to_add = []
for field_key, field_config in NORMAL_DEVICE_FIELDS.items(): all_fields = FIELD_ATTRIBUTES
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 category = None
if field_config.setter is True: if field_config.setter is True:
category = EntityCategory.DIAGNOSTIC category = EntityCategory.DIAGNOSTIC
# Ignore setters if controls enabled
if field_config.setter is True and use_controls is True:
continue
sensors_to_add.append( sensors_to_add.append(
BluettiBinarySensor( BluettiBinarySensor(
hass.data[DOMAIN][entry.entry_id], 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,
category=category, category=category,
) )
) )
@@ -83,42 +89,54 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""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_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
@property @property
def available(self) -> bool: def available(self) -> bool:
if self._address is None: """Return if entity is available."""
return False return self._attr_available
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 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))
if not isinstance(self.coordinator.data, dict): if not isinstance(self.coordinator.data, dict):
_LOGGER.error( _LOGGER.debug(
"Invalid data from coordinator (sensor.%s)", self._attr_unique_id "Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id)
) )
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.async_write_ha_state()
return return
if not isinstance(response_data, bool): if not isinstance(response_data, bool):
_LOGGER.warning( _LOGGER.warning(
"Invalid response data type from coordinator (sensor.%s): %s", "Invalid response data type from coordinator (binary_sensor.%s): %s",
self._attr_unique_id, unique_id_loggable(self._attr_unique_id),
response_data, response_data,
) )
self._attr_available = False
self.async_write_ha_state()
return return
self._attr_available = True
self._attr_is_on = self.coordinator.data[self._response_key] is True self._attr_is_on = self.coordinator.data[self._response_key] is True
self.async_write_ha_state() self.async_write_ha_state()
@@ -0,0 +1,3 @@
from .const import *
from .exceptions import *
from .utils.commands import ReadHoldingRegisters
@@ -0,0 +1,65 @@
"""Bluetti device."""
# Reduced copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/bluetti_device.py
from typing import Any, List
from ..utils.commands import ReadHoldingRegisters, WriteSingleRegister
from ..utils.struct import BoolField, DeviceStruct, EnumField
class BluettiDevice:
struct: DeviceStruct
def __init__(self, address: str, type: str, sn: str):
self.address = address
self.type = type
self.sn = sn
def parse(self, address: int, data: bytes) -> dict:
return self.struct.parse(address, data)
@property
def pack_num_max(self):
"""
A given device has a maximum number of battery packs, including the
internal battery if it has one. We can provide this information statically
so it's not necessary to poll the device.
"""
return 1
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device has an optimal set of commands for polling"""
raise NotImplementedError
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device may have a set of commands for polling pack data"""
return []
@property
def writable_ranges(self) -> List[range]:
"""The address ranges that are writable"""
return []
def has_field(self, field: str):
return any(f.name == field for f in self.struct.fields)
def has_field_setter(self, field: str):
matches = [f for f in self.struct.fields if f.name == field]
return any(any(f.address in r for r in self.writable_ranges) for f in matches)
def build_setter_command(self, field: str, value: Any):
matches = [f for f in self.struct.fields if f.name == field]
device_field = next(
f for f in matches if any(f.address in r for r in self.writable_ranges)
)
# Convert value to an integer
if isinstance(device_field, EnumField):
value = device_field.enum[value].value
elif isinstance(device_field, BoolField):
value = 1 if value else 0
return WriteSingleRegister(device_field.address, value)
@@ -0,0 +1,58 @@
"""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)]
@@ -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,229 @@
"""Device reader."""
import asyncio
import logging
from typing import Any, Callable, List, cast
import async_timeout
from bleak import BleakClient, BleakError
from ..base_devices.BluettiDevice import BluettiDevice
from ..const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID
from ..exceptions import BadConnectionError, ModbusError, ParseError
from ..utils.commands import ReadHoldingRegisters
_LOGGER = logging.getLogger(__name__)
class DeviceReader:
def __init__(
self,
bleak_client: BleakClient,
bluetti_device: BluettiDevice,
future_builder_method: Callable[[], asyncio.Future[Any]],
persistent_conn: bool = False,
polling_timeout: int = 45,
max_retries: int = 5,
) -> None:
self.client = bleak_client
self.bluetti_device = bluetti_device
self.create_future = future_builder_method
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
self.has_notifier = False
self.notify_future: asyncio.Future[Any] | None = None
self.current_command = None
self.notify_response = bytearray()
# polling mutex to guard against switches
self.polling_lock = asyncio.Lock()
async def read_data(
self, filter_registers: List[ReadHoldingRegisters] | None = None
) -> dict | None:
_LOGGER.info("Reading data")
if self.bluetti_device is None:
_LOGGER.error("Device is None")
return None
polling_commands = self.bluetti_device.polling_commands
pack_commands = self.bluetti_device.pack_polling_commands
if filter_registers is not None:
polling_commands = filter_registers
pack_commands = []
parsed_data: dict = {}
async with self.polling_lock:
try:
async with async_timeout.timeout(self.polling_timeout):
# Reconnect if not connected
for attempt in range(1, self.max_retries + 1):
try:
if not self.client.is_connected:
await self.client.connect()
break
except Exception as e:
if attempt == self.max_retries or attempt == 1:
raise # pass exception on max_retries attempt
else:
_LOGGER.warning(
f"Connect unsucessful (attempt {attempt}): {e}. Retrying..."
)
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
# Execute polling commands
for command in polling_commands:
try:
body = command.parse_response(
await self._async_send_command(command)
)
_LOGGER.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
_LOGGER.warning("Got a parse exception")
# Execute pack polling commands
if len(pack_commands) > 0:
_LOGGER.debug("Polling battery packs")
for pack in range(1, self.bluetti_device.pack_num_max + 1):
# Set current pack number
await self._async_send_command(
self.bluetti_device.build_setter_command(
"pack_num", pack
)
)
# We need to wait after switching packs for the data to be available
await asyncio.sleep(5)
pack_ok = False
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)
if not pack_ok: # Only check this once
pack_number = parsed.get("pack_num")
if (
not isinstance(pack_number, int)
or pack_number != pack
):
_LOGGER.debug(
"Parsed pack_num(%s) does not match expected '%s'",
pack_number,
pack,
)
continue
pack_ok = True
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.warning("Polling single command timed out")
except ModbusError as err:
_LOGGER.warning(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
# 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,59 @@
"""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", 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(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,59 @@
"""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", 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(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,83 @@
"""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_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, scale=.1)
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", 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(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,81 @@
"""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_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, scale=.1)
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", 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(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,77 @@
"""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", 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(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,77 @@
"""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", 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(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 or len(match) < 2:
return "Unknown"
return match[1]
@@ -0,0 +1,207 @@
"""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]], scale: int):
self.range = range
self.scale = scale
super().__init__(name, address, 1)
def parse(self, data: bytes) -> int:
val = struct.unpack("!H", data)[0]
return val * self.scale
def in_range(self, val: int) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class IntField(DeviceField):
def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]]):
self.range = range
super().__init__(name, address, 1)
def parse(self, data: bytes) -> int:
return struct.unpack(">h", data)[0]
def in_range(self, val: int) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class BoolField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 1)
def parse(self, data: bytes) -> bool:
return struct.unpack("!H", data)[0] == 1
class EnumField(DeviceField):
def __init__(self, name: str, address: int, enum: Type[Enum]):
self.enum = enum
super().__init__(name, address, 1)
def parse(self, data: bytes) -> Any:
val = struct.unpack("!H", data)[0]
return self.enum(val)
class DecimalField(DeviceField):
def __init__(
self, name: str, address: int, scale: int, range: Optional[Tuple[int, int]]
):
self.scale = scale
self.range = range
super().__init__(name, address, 1)
def parse(self, data: bytes) -> Decimal:
val = Decimal(struct.unpack("!H", data)[0])
return val / 10 ** self.scale
def in_range(self, val: Decimal) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class DecimalArrayField(DeviceField):
def __init__(self, name: str, address: int, size: int, scale: int):
self.scale = scale
super().__init__(name, address, size)
def parse(self, data: bytes) -> Decimal:
values = list(struct.unpack(f"!{self.size}H", data))
return [Decimal(v) / 10 ** self.scale for v in values]
class StringField(DeviceField):
"""Fixed-width null-terminated string field"""
def parse(self, data: bytes) -> str:
return data.rstrip(b"\0").decode("ascii")
class SwapStringField(DeviceField):
"""Fixed-width null-terminated string field"""
def parse(self, data: bytes) -> str:
return swap_bytes(data).rstrip(b"\0").decode("ascii")
class VersionField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 2)
def parse(self, data: bytes) -> int:
values = struct.unpack("!2H", data)
return Decimal(values[0] + (values[1] << 16)) / 100
class SerialNumberField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 4)
def parse(self, data: bytes) -> int:
values = struct.unpack("!4H", data)
return values[0] + (values[1] << 16) + (values[2] << 32) + (values[3] << 48)
class DeviceStruct:
fields: List[DeviceField]
def __init__(self):
self.fields = []
def add_uint_field(self, name: str, address: int, range: Tuple[int, int] = None, scale: int = 1):
self.fields.append(UintField(name, address, range, scale))
def add_int_field(self, name: str, address: int, range: Tuple[int, int] = None):
self.fields.append(IntField(name, address, range))
def add_bool_field(self, name: str, address: int):
self.fields.append(BoolField(name, address))
def add_enum_field(self, name: str, address: int, enum: Type[Enum]):
self.fields.append(EnumField(name, address, enum))
def add_decimal_field(
self, name: str, address: int, scale: int, range: Tuple[int, int] = None
):
self.fields.append(DecimalField(name, address, scale, range))
def add_decimal_array_field(self, name: str, address: int, size: int, scale: int):
self.fields.append(DecimalArrayField(name, address, size, scale))
def add_string_field(self, name: str, address: int, size: int):
self.fields.append(StringField(name, address, size))
def add_swap_string_field(self, name: str, address: int, size: int):
self.fields.append(SwapStringField(name, address, size))
def add_version_field(self, name: str, address: int):
self.fields.append(VersionField(name, address))
def add_sn_field(self, name: str, address: int):
self.fields.append(SerialNumberField(name, address))
def parse(self, starting_address: int, data: bytes) -> dict:
# Offsets and size are counted in 2 byte chunks, so for the range we
# need to divide the byte size by 2
data_size = int(len(data) / 2)
# Filter out fields not in range
r = range(starting_address, starting_address + data_size)
fields = [
f for f in self.fields if f.address in r and f.address + f.size - 1 in r
]
# Parse fields
parsed = {}
for f in fields:
data_start = 2 * (f.address - starting_address)
field_data = data[data_start : data_start + 2 * f.size]
val = f.parse(field_data)
# Skip if the value is "out-of-range" - sometimes the sensors
# report weird values
if not f.in_range(val):
continue
parsed[f.name] = val
return parsed
+117 -7
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,21 @@ 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
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 .bluetti_bt_lib.utils.device_builder import get_type_by_bt_name
from .const import DOMAIN from .bluetti_bt_lib.bluetooth.device_recognizer import recognize_device
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__)
@@ -35,6 +47,14 @@ 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()
# Get device type if needed
if isinstance(discovery_info.name, str) and discovery_info.name.startswith("PBOX"):
bleak_device = BleakClient(discovery_info.device)
device_type = await recognize_device(bleak_device, self.hass.loop.create_future)
_LOGGER.info("Device identified as %s", device_type)
discovery_info.name = discovery_info.name.replace("PBOX", device_type.strip())
self._discovery_info = discovery_info self._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()
@@ -49,13 +69,13 @@ 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),
}, },
) )
@@ -86,3 +106,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,
}
),
)
+42
View File
@@ -4,3 +4,45 @@ DOMAIN = "bluetti_bt"
MANUFACTURER = "Bluetti" MANUFACTURER = "Bluetti"
CONF_OPTIONS = "options" CONF_OPTIONS = "options"
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"
SUPPORTED_MODELS = [
"AC60",
"AC200M",
"AC300",
"AC500",
"EB3A",
"EP500",
"EP500P",
"EP600",
"EP760",
]
CONTROL_FIELDS = [
"ac_output_on_switch",
"dc_output_on_switch",
"power_off",
"eco_on",
"power_lifting_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",
"power_generation",
"total_ac_consumption",
"total_grid_consumption",
"total_grid_feed",
]
+41 -118
View File
@@ -2,13 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import cast
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,13 +13,10 @@ 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, from .utils import mac_loggable
ParseError,
build_device,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -30,19 +24,43 @@ _LOGGER = logging.getLogger(__name__)
class PollingCoordinator(DataUpdateCoordinator): class PollingCoordinator(DataUpdateCoordinator):
"""Polling coordinator.""" """Polling coordinator."""
def __init__(self, hass: HomeAssistant, address, device_name: str): def __init__(
self,
hass: HomeAssistant,
address: str,
device_name: str,
polling_interval: int,
persistent_conn: bool,
polling_timeout: int,
max_retries: int,
):
"""Initialize coordinator.""" """Initialize coordinator."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name="Bluetti polling coordinator", name="Bluetti polling coordinator",
update_interval=timedelta(seconds=20), update_interval=timedelta(seconds=polling_interval),
)
self.address = address
# 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
client = BleakClient(device)
bluetti_device = build_device(address, device_name)
self.reader = DeviceReader(
client,
bluetti_device,
self.hass.loop.create_future,
persistent_conn=persistent_conn,
polling_timeout=polling_timeout,
max_retries=max_retries,
) )
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): async def _async_update_data(self):
"""Fetch data from API endpoint. """Fetch data from API endpoint.
@@ -50,106 +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.logger.debug("Polling data")
device = bluetooth.async_ble_device_from_address(self.hass, self._address) # Check if device is connected
if device is None: if bluetooth.async_address_present(self.hass, self.address, connectable=True) is False:
self.logger.error("Device %s not available", self._address) self.logger.warning("Device not connected")
self.last_update_success = False
return None return None
if self.bluetti_device is None: return await self.reader.read_data()
self.logger.error("Device type for %s not found", self._address)
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.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except TimeoutError:
self.logger.error(
"Polling timed out (address: %s)", self._address
)
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 for device %s", self._address)
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))
+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.2" "version": "0.0.24"
} }
+63 -37
View File
@@ -1,33 +1,30 @@
"""Bluetti BT sensors.""" """Bluetti BT sensors."""
from __future__ import annotations from __future__ import annotations
from enum import Enum
import logging import logging
from decimal import * from decimal import Decimal
from homeassistant.components import bluetooth from homeassistant.components.sensor import SensorEntity
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
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 NORMAL_DEVICE_FIELDS, MqttFieldType from .bluetti_bt_lib.utils.device_builder import build_device
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import DOMAIN, CONF_OPTIONS from .const import DATA_COORDINATOR, DOMAIN, DIAGNOSTIC_FIELDS
from .coordinator import PollingCoordinator from .coordinator import PollingCoordinator
from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -50,36 +47,43 @@ async def async_setup_entry(
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
sensors_to_add = [] sensors_to_add = []
for field_key, field_config in NORMAL_DEVICE_FIELDS.items(): all_fields = FIELD_ATTRIBUTES
if len(bluetti_device.pack_polling_commands) > 0:
# add pack fields for device
_LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max)
for pack in range (1, bluetti_device.pack_num_max + 1):
for name, field in PACK_FIELD_ATTRIBUTES(pack).items():
all_fields.update({name+str(pack): field})
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key): if bluetti_device.has_field(field_key):
category = None category = None
if field_config.setter is True: 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], 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( field_config.unit_of_measurement,
CONF_UNIT_OF_MEASUREMENT, "" field_config.device_class,
), field_config.state_class,
field_config.home_assistant_extra.get(CONF_DEVICE_CLASS, ""),
field_config.home_assistant_extra.get(CONF_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], 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,
) )
) )
@@ -106,12 +110,14 @@ 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_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
@@ -121,22 +127,38 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
if self._address is None: """Return if entity is available."""
return False return self._attr_available
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.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
_LOGGER.error( return
"Invalid data from coordinator (sensor.%s)", self._attr_unique_id
if self.coordinator.data is None:
_LOGGER.debug(
"Data from coordinator is None",
) )
self._attr_available = False
self.async_write_ha_state()
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
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)
self._attr_available = False
self.async_write_ha_state()
return return
if ( if (
@@ -144,23 +166,27 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
and not isinstance(response_data, float) and not isinstance(response_data, float)
and not isinstance(response_data, complex) and not isinstance(response_data, complex)
and not isinstance(response_data, Decimal) and not isinstance(response_data, Decimal)
and not isinstance(response_data, Enum)
): ):
_LOGGER.warning( _LOGGER.warning(
"Invalid response data type from coordinator (sensor.%s): %s has type %s", "Invalid response data type from coordinator (sensor.%s): %s has type %s",
self._attr_unique_id, unique_id_loggable(self._attr_unique_id),
response_data, response_data,
type(response_data), type(response_data),
) )
self._attr_available = False
self.async_write_ha_state()
return return
self._attr_available = True
# Different for enum and numeric # Different for enum and numeric
if ( if (
self._options is not None self._options is not None
and isinstance(response_data, int) and isinstance(response_data, Enum)
and response_data < len(self._options)
): ):
# Enum # Enum
self._attr_native_value = self._options[response_data] self._attr_native_value = response_data.name
else: else:
# Numeric # Numeric
self._attr_native_value = response_data self._attr_native_value = response_data
+192
View File
@@ -0,0 +1,192 @@
"""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_bt_lib.base_devices.BluettiDevice import BluettiDevice
from .bluetti_bt_lib.const import WRITE_UUID
from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from .bluetti_bt_lib.utils.device_builder import build_device
from . import device_info as dev_info, get_unique_id
from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN
from .coordinator import PollingCoordinator
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)
sensors_to_add = []
all_fields = FIELD_ATTRIBUTES
for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key):
if field_config.type == FieldType.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.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.reader.client
self._polling_lock = coordinator.reader.polling_lock
e_name = f"{device_info.get('name')} {name}"
self._address = address
self._response_key = response_key
self._entry_id = entry_id
self._attr_device_info = device_info
self._attr_has_entity_name = True
self._attr_name = 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
@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.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))
if not isinstance(self.coordinator.data, dict):
_LOGGER.debug(
"Invalid data from coordinator (switch.%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):
_LOGGER.warning(
"Invalid response data type from coordinator (switch.%s): %s",
unique_id_loggable(self._attr_unique_id),
response_data,
)
self._attr_available = False
self.async_write_ha_state()
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(
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.reader.persistent_conn:
await self._client.disconnect()
await self.coordinator.async_request_refresh()
@@ -0,0 +1,30 @@
{
"config": {
"step": {
"user": {
"data": {
"address": "Gerät",
"type": "Modell (erforderlich bei PBOX Geräten)"
}
}
}
},
"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,30 @@
{
"config": {
"step": {
"user": {
"data": {
"address": "Device",
"type": "Model (required for PBOX devices)"
}
}
}
},
"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/
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()