Compare commits

...
169 Commits
Author SHA1 Message Date
Patrick762 96a5cd4ee1 Merge pull request #174 from Patrick762/option-flow-update
Remove deprecated constructor in option flow
2025-06-14 21:16:32 +02:00
Patrick762 5204c165d8 bump version 2025-06-14 21:16:12 +02:00
Patrick762 869f80314b fix option flow 2025-06-14 21:12:02 +02:00
Patrick762 9ec0b539e1 remove deprecated constructor in option flow 2025-06-14 20:51:11 +02:00
Patrick762 8912a0bd08 Merge pull request #152 from Patrick762/unittest-fix
Unittest fix
2025-02-07 14:43:04 +01:00
Patrick762 db0147e7c1 added more requirements and dockerfile for tests 2025-02-07 14:30:42 +01:00
Patrick762 c5f985b5f6 Merge pull request #143 from Patrick762/unittests
added missing ac200l test
2024-10-25 17:00:03 +02:00
Patrick762 63f798ca5a added missing ac200l test 2024-10-25 16:58:29 +02:00
Patrick762 483ca4049d Merge pull request #142 from Patrick762/unittests
Unittest Action
2024-10-25 16:49:35 +02:00
Patrick762 de1ca78c90 fixed comment 2024-10-25 16:48:18 +02:00
Patrick762 a72f42eb4a added simple test for struct 2024-10-25 16:47:26 +02:00
Patrick762 6ce8f24477 added pip install requirements.txt 2024-10-25 16:32:19 +02:00
Patrick762 28cd715327 removed pip install 2024-10-25 16:30:50 +02:00
Patrick762 49c1409656 Update test.yaml 2024-10-25 16:29:00 +02:00
Patrick762 d61459c011 Create test.yaml 2024-10-25 16:23:21 +02:00
Patrick762 edf5e87720 Merge pull request #128 from Patrick762/version-update
update integration manifest version
2024-08-04 12:13:29 +02:00
Patrick762 742964b78b update integration manifest version 2024-08-04 12:12:55 +02:00
Patrick762 21e89e738d Merge pull request #127 from Kathou99/main
Update config_flow.py
2024-08-04 12:08:16 +02:00
Patrick762 52fd1932fa Merge pull request #118 from AChep/main
Various imprvements for AC300 & AC500
2024-08-04 12:07:43 +02:00
KathouQC(GirlsPowers) 3e54283faf Update config_flow.py
fix this bug: OptionsFlowHandler.async_step_init: Config entry f3a445b566f0876e0d79f02059b7cb2a has not been changed
2024-08-03 18:15:49 -04:00
Artem Chepurnyi 9be8aa47ac Add dsp/arm_version to diagnostic fields 2024-08-03 21:36:27 +03:00
Artem ChepurnyiandPatrick762 8967f3379c Fix a typo in the Polling commands text
Co-authored-by: Patrick762 <[email protected]>
2024-08-03 21:32:28 +03:00
Patrick762 6f9afedfdb Merge pull request #119 from kvanonavk/main
AC180P Advanced Integration
2024-07-19 16:17:16 +02:00
Patrick762 fe23dd00cd Merge pull request #122 from Patrick762/ac70-fix
Added ac70 auto detection
2024-07-19 16:13:48 +02:00
Patrick762 566761dba7 update version 2024-07-19 16:13:19 +02:00
Patrick762 33cb6e6fbd added ac70 auto detection 2024-07-19 15:02:57 +02:00
kvanonavk 5823136280 Merge branch 'Patrick762:main' into main 2024-07-05 14:44:12 +03:00
kvanonavk 7b6f845edb fixes 2024-07-05 14:42:56 +03:00
kvanonavk 1fbcf83b1b simple set charging mode (no turbo) 2024-07-01 18:16:58 +03:00
Artem Chepurnyi da2017a24b chore: Mention that AC500 is tested 2024-06-30 17:14:30 +03:00
Artem Chepurnyi 83f0db267c Merge branch 'Patrick762:main' into main 2024-06-30 17:12:50 +03:00
Patrick762 149633f9c8 Increase version number 2024-06-30 15:04:23 +02:00
Patrick762 9ab7cefda7 Merge pull request #116 from matthewpucc/main
Support AC70 and AC70P Device
2024-06-30 15:03:35 +02:00
Patrick762 52853d6d3e Merge pull request #117 from rotx/fix-multiply
Fix multiplication of Decimal with float
2024-06-30 15:03:14 +02:00
kvanonavk c96f551024 fixes 2024-06-30 14:11:31 +03:00
kvanonavk 75209ee1aa readme fix 2 2024-06-30 13:59:45 +03:00
kvanonavk 7396616113 readme fix 2024-06-30 13:56:58 +03:00
kvanonavk a7f7ff1843 fixes 2024-06-30 13:55:10 +03:00
kvanonavk cc755b3003 test + fix 2024-06-30 13:25:39 +03:00
kvanonavk e5f690ef3b AC180P more data and functionality 2024-06-30 12:24:52 +03:00
Artem Chepurnyi fa9bcf63ca fix: Actually retry connecting to a device 2024-06-29 17:04:43 +03:00
Artem Chepurnyi 211f1971c5 chore: Remove .DS_Store from the repo 2024-06-29 16:48:09 +03:00
Artem Chepurnyi a1e9c180b3 improvement: Automatically build polling commands basing on the fields 2024-06-29 16:45:59 +03:00
Artem Chepurnyi 0e5c1a0b44 fix: Increase polling interval and timeout #109 2024-06-29 16:45:24 +03:00
Artem Chepurnyi 0f33a31ee5 fix: Correctly read battery packs' values - wait before setting the target pack number #109 2024-06-29 16:44:44 +03:00
Artem Chepurnyi 23551ae829 improvement: Add ARM and DSP versions 2024-06-29 16:42:32 +03:00
Artem Chepurnyi a54b36a90b fix: Initialize sensor. _unavailable_counter with a 5 2024-06-29 16:42:04 +03:00
Artem Chepurnyi f85661345c fix(AC500): Change max pack number to 6 2024-06-29 13:58:22 +03:00
Robert Muchsel 3d3401c942 Fix multiplication of Decimal with float 2024-06-21 20:52:48 -05:00
Matthew Puccerella f50232ee89 Merge pull request #1 from matthewpucc/support_ac70_devices
Add support AC70 and AC70P devices
2024-06-20 16:12:53 -07:00
Matthew Puccerella 5922362772 Add support AC70 and AC70P devices 2024-06-20 23:11:47 +00:00
Patrick762 6478825503 Merge pull request #107 from Patrick762/next
Fixed values and improved unavailable handling
2024-06-02 14:06:20 +02:00
Patrick762 3169ee182e increase version number 2024-06-02 14:03:02 +02:00
Patrick762 ece1fc4129 only set unavailable after 5 retries 2024-06-02 13:58:21 +02:00
Patrick762 4d54e048c5 ignore packs not connected 2024-06-02 13:49:18 +02:00
Patrick762 09a9112279 check pack_num after setting it 2024-06-02 13:48:45 +02:00
Patrick762 b37276ec52 updated ac200l sensors based on feedback 2024-06-02 13:12:43 +02:00
Patrick762 4f88792371 Update bug_report.yaml 2024-05-31 17:16:26 +02:00
Patrick762 7da392d85a Merge pull request #54 from Patrick762/dev
[WIP] Release
2024-05-31 17:05:16 +02:00
Patrick762 03ea349cbf set new version to 0.1 2024-05-31 17:04:41 +02:00
Patrick762 5a762266c0 Merge pull request #104 from Patrick762/values-fix-2
Values and pack polling fix
2024-05-31 17:02:07 +02:00
Patrick762 12a59346e9 fix device type info 2024-05-31 16:59:46 +02:00
Patrick762 206099ab64 increase version number 2024-05-31 14:23:43 +02:00
Patrick762 6be2abaaf2 fix pack polling 2024-05-31 14:23:07 +02:00
Patrick762 c3bfad0ea6 add pack sensors if available 2024-05-31 13:46:09 +02:00
Patrick762 b548528085 updated pack polling 2024-05-31 12:55:46 +02:00
Patrick762 6e433e3551 fix total_battery_voltage 2024-05-31 12:14:51 +02:00
Patrick762 b34d0a3039 fix ac300 and ac500 solar string values and renamed entity 2024-05-31 12:06:21 +02:00
Patrick762 2922c5cb90 add multiplier to decimal field 2024-05-31 12:05:09 +02:00
Patrick762 9af3d67ec6 removed binary sensors for disabled control entities to prevent duplicates 2024-05-31 11:34:25 +02:00
Patrick762 221da9a38d fix internal_power sensors for ac300 and ac500 2024-05-31 11:24:20 +02:00
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 7f576df84b Merge pull request #58 from Patrick762/Patrick762-patch-1
Create LICENSE
2024-02-18 20:15:44 +01:00
Patrick762 8bc8ba4a4b Create LICENSE 2024-02-18 20:15:19 +01:00
Patrick762 213ee82e63 Merge pull request #56 from Patrick762/Patrick762-patch-1
Update versions in bug_report template
2024-02-18 17:22:21 +01:00
Patrick762 c08099ca4f Update versions in bug_report template 2024-02-18 17:21:36 +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 4008dc844e Merge pull request #53 from Patrick762/Patrick762-patch-1
Create FUNDING.yml
2024-02-18 12:43:17 +01:00
Patrick762 e27c28b5c8 Create FUNDING.yml 2024-02-18 12:41:25 +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
50 changed files with 2535 additions and 929 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*/__pycache__/
*.pyc
.DS_Store
+1
View File
@@ -0,0 +1 @@
github: Patrick762
+61
View File
@@ -0,0 +1,61 @@
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: textarea
id: version
attributes:
label: What version of our software are you running?
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: Unittests
on:
push:
pull_request:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run unittests
run: python3 -m unittest discover -s tests -p "*_test.py"
+1
View File
@@ -1,2 +1,3 @@
*/__pycache__/ */__pycache__/
*.pyc *.pyc
.DS_Store
+9
View File
@@ -0,0 +1,9 @@
FROM python:3
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY tests ./tests
COPY custom_components ./custom_components
RUN python3 -m unittest discover -s tests -p "*_test.py"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Patrick762
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10 -6
View File
@@ -5,7 +5,8 @@
Bluetti Integration for Home Assistant Bluetti Integration for Home Assistant
Based on [bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt). ## Disclaimer
This integration is provided without any warranty or support by Bluetti (unfortunately). I do not take responsibility for any problems it may cause in all cases. Use it at your own risk.
## Installation ## Installation
To install this integration, you first need [HACS](https://hacs.xyz/) installed. To install this integration, you first need [HACS](https://hacs.xyz/) installed.
@@ -14,19 +15,22 @@ After the installation, you can use this button to install the integration:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Patrick762&repository=hassio-bluetti-bt&category=integration) [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Patrick762&repository=hassio-bluetti-bt&category=integration)
### Supported devices: ### Supported devices:
(based on [supported devices of bluetti_mqtt](https://github.com/warhammerkid/bluetti_mqtt/tree/main/bluetti_mqtt/core/devices))
- AC60 - AC60
- AC70 (basic data)
- AC70P (untested)
- AC180 (basic data)
- AC180P (tested)
- AC200L (untested)
- AC200M - AC200M
- AC300 (tested) - AC300 (tested)
- AC500 - AC500 (tested)
- EB3A - EB3A
- EP500 - EP500
- EP500P - EP500P
- EP600 (tested) - EP600 (tested)
- EP760 (basic data)
### Available sensors: - EP800 (basic data)
All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)).
### Available controls: ### Available controls:
If enabled in the Integration options (you need to reload the integration if you change this option): If enabled in the Integration options (you need to reload the integration if you change this option):
+10 -23
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import re import re
import logging import logging
from typing import List
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -25,19 +26,21 @@ from .const import (
) )
from .coordinator import PollingCoordinator from .coordinator import PollingCoordinator
PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS: List[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bluetti Powerstation from a config entry.""" """Set up Bluetti Powerstation from a config entry."""
_LOGGER.debug("Init Bluetti BT Integration")
address = entry.data.get(CONF_ADDRESS) address = entry.data.get(CONF_ADDRESS)
device_name = entry.data.get(CONF_NAME) device_name = entry.data.get(CONF_NAME)
use_controls = entry.data.get(CONF_USE_CONTROLS) use_controls = entry.data.get(CONF_USE_CONTROLS)
polling_interval = entry.data.get(CONF_POLLING_INTERVAL, 20) polling_interval = entry.data.get(CONF_POLLING_INTERVAL, 60)
persistent_conn = entry.data.get(CONF_PERSISTENT_CONN, False) persistent_conn = entry.data.get(CONF_PERSISTENT_CONN, False)
polling_timeout = entry.data.get(CONF_POLLING_TIMEOUT, 45) polling_timeout = entry.data.get(CONF_POLLING_TIMEOUT, 120)
max_retries = entry.data.get(CONF_MAX_RETRIES, 5) max_retries = entry.data.get(CONF_MAX_RETRIES, 5)
if address is None: if address is None:
@@ -52,10 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False) hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False)
# Create coordinator for polling # Create coordinator for polling
_LOGGER.debug("Creating coordinator")
coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries) coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator) hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator)
_LOGGER.debug("Creating entities")
platforms: list = PLATFORMS platforms: list = PLATFORMS
if use_controls is True: if use_controls is True:
_LOGGER.warning("You are using controls with this integration at your own risk!") _LOGGER.warning("You are using controls with this integration at your own risk!")
@@ -64,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Setup platforms # Setup platforms
await hass.config_entries.async_forward_entry_setups(entry, platforms) await hass.config_entries.async_forward_entry_setups(entry, platforms)
_LOGGER.debug("Setup done")
return True return True
@@ -83,23 +90,3 @@ def get_unique_id(name: str, sensor_type: str | None = None):
if sensor_type is not None: if sensor_type is not None:
return f"{sensor_type}.{res}" return f"{sensor_type}.{res}"
return res return res
def get_type_by_bt_name(bt_name: str):
"""Get the device type."""
dev_type = "Unknown"
if bt_name.startswith("AC200M"):
dev_type = "AC200M"
elif bt_name.startswith("AC300"):
dev_type = "AC300"
elif bt_name.startswith("AC500"):
dev_type = "AC500"
elif bt_name.startswith("AC60"):
dev_type = "AC60"
elif bt_name.startswith("EB3A"):
dev_type = "EB3A"
elif bt_name.startswith("EP500"):
dev_type = "EP500"
elif bt_name.startswith("EP600"):
dev_type = "EP600"
return dev_type
+50 -25
View File
@@ -18,16 +18,12 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
) )
from bluetti_mqtt.bluetooth import build_device from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, FieldType
from bluetti_mqtt.mqtt_client import ( from .bluetti_bt_lib.utils.device_builder import build_device
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS from .const import DATA_COORDINATOR, DOMAIN, CONF_USE_CONTROLS
from .coordinator import PollingCoordinator, DummyDevice from .coordinator import PollingCoordinator
from .utils import unique_id_loggable from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -40,6 +36,7 @@ async def async_setup_entry(
device_name = entry.data.get(CONF_NAME) device_name = entry.data.get(CONF_NAME)
address = entry.data.get(CONF_ADDRESS) address = entry.data.get(CONF_ADDRESS)
use_controls = entry.data.get(CONF_USE_CONTROLS, False)
if address is None: if address is None:
_LOGGER.error("Device has no address") _LOGGER.error("Device has no address")
@@ -49,18 +46,14 @@ async def async_setup_entry(
# Add sensors according to device_info # Add sensors according to device_info
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)
sensors_to_add = [] sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS all_fields = FIELD_ATTRIBUTES
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
for field_key, field_config in all_fields.items(): for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key): if bluetti_device.has_field(field_key):
if field_config.type == MqttFieldType.BOOL: if field_config.type == FieldType.BOOL:
category = None
if field_config.setter is True: if field_config.setter is True:
category = EntityCategory.DIAGNOSTIC continue
sensors_to_add.append( sensors_to_add.append(
BluettiBinarySensor( BluettiBinarySensor(
@@ -68,8 +61,7 @@ async def async_setup_entry(
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
category=category,
) )
) )
@@ -86,26 +78,59 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
address, address,
response_key: str, response_key: str,
name: str, name: str,
category: EntityCategory | None = None,
): ):
"""Init battery entity.""" """Init battery entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_has_entity_name = True
e_name = f"{device_info.get('name')} {name}" e_name = f"{device_info.get('name')} {name}"
self._address = address self._address = address
self._response_key = response_key self._response_key = response_key
self._unavailable_counter = 5
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_name = name
self._attr_available = False self._attr_available = False
self._attr_unique_id = get_unique_id(e_name) self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available
def _set_available(self):
"""Set sensor as available."""
self._attr_available = True
self._unavailable_counter = 0
self._attr_extra_state_attributes = {}
self.async_write_ha_state()
def _set_unavailable(self, cause: str = "Unknown"):
"""Set sensor as unavailable."""
self._unavailable_counter += 1
self._attr_extra_state_attributes = {
"unavailable_counter": self._unavailable_counter,
"unavailable_cause": cause,
}
if self._unavailable_counter >= 5:
self._attr_available = False
self.async_write_ha_state()
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return
if self.coordinator.data is None:
_LOGGER.debug(
"Data from coordinator is None",
)
self._set_unavailable("Data is None")
return return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
@@ -113,12 +138,12 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
_LOGGER.debug( _LOGGER.debug(
"Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id) "Invalid data from coordinator (binary_sensor.%s)", unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False self._set_unavailable("Invalid data")
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._set_unavailable("No data")
return return
if not isinstance(response_data, bool): if not isinstance(response_data, bool):
@@ -127,9 +152,9 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
unique_id_loggable(self._attr_unique_id), unique_id_loggable(self._attr_unique_id),
response_data, response_data,
) )
self._attr_available = False self._set_unavailable("Invalid data type")
return return
self._attr_available = True self._set_available()
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,70 @@
"""Bluetti device."""
# Reduced copy of https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/core/devices/bluetti_device.py
from typing import Any, List
from ..utils.commands import ReadHoldingRegisters, WriteSingleRegister
from ..utils.struct import BoolField, DeviceStruct, EnumField
class BluettiDevice:
struct: DeviceStruct
def __init__(self, address: str, type: str, sn: str):
self.address = address
self.type = type
self.sn = sn
def parse(self, address: int, data: bytes) -> dict:
return self.struct.parse(address, data)
@property
def pack_num_max(self):
"""
A given device has a maximum number of battery packs, including the
internal battery if it has one. We can provide this information statically
so it's not necessary to poll the device.
"""
return 1
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device has an optimal set of commands for polling"""
raise NotImplementedError
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
"""A given device may have a set of commands for polling pack data"""
return []
@property
def writable_ranges(self) -> List[range]:
"""The address ranges that are writable"""
return []
@property
def pack_num_field(self) -> List[ReadHoldingRegisters]:
"""The address 'range' of the pack num result. Matches pack_num_result"""
return []
def has_field(self, field: str):
return any(f.name == field for f in self.struct.fields)
def has_field_setter(self, field: str):
matches = [f for f in self.struct.fields if f.name == field]
return any(any(f.address in r for r in self.writable_ranges) for f in matches)
def build_setter_command(self, field: str, value: Any):
matches = [f for f in self.struct.fields if f.name == field]
device_field = next(
f for f in matches if any(f.address in r for r in self.writable_ranges)
)
# Convert value to an integer
if isinstance(device_field, EnumField):
value = device_field.enum[value].value
elif isinstance(device_field, BoolField):
value = 1 if value else 0
return WriteSingleRegister(device_field.address, value)
@@ -0,0 +1,66 @@
"""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)
self.struct.add_version_field('arm_version', 23)
self.struct.add_version_field('dsp_version', 25)
# Power IO
self.struct.add_uint_field("dc_input_power", 36)
self.struct.add_uint_field("ac_input_power", 37)
self.struct.add_uint_field("ac_output_power", 38)
self.struct.add_uint_field("dc_output_power", 39)
# Power IO statistics
self.struct.add_decimal_field(
"power_generation", 41, 1
) # Total power generated since last reset (kwh)
# Battery
self.struct.add_uint_field("total_battery_percent", 43)
# Output state
self.struct.add_bool_field("ac_output_on", 48)
self.struct.add_bool_field("dc_output_on", 49)
# Pack selector
self.struct.add_uint_field("pack_num", 3006) # internal
# Output controls
self.struct.add_bool_field("ac_output_on_switch", 3007)
self.struct.add_bool_field("dc_output_on_switch", 3008)
super().__init__(address, type, sn)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(10, 10),
ReadHoldingRegisters(36, 4),
ReadHoldingRegisters(41, 1),
ReadHoldingRegisters(43, 1),
ReadHoldingRegisters(48, 2),
ReadHoldingRegisters(3007, 2),
]
@property
def writable_ranges(self) -> List[range]:
return [range(3006, 3009)]
@property
def pack_num_field(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(96, 1),
]
@@ -0,0 +1,69 @@
"""Base device definition for V2 Protocol devices."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..utils.struct import DeviceStruct
from .BluettiDevice import BluettiDevice
class ProtocolV2Device(BluettiDevice):
def __init__(self, address: str, type: str, sn: str):
self.struct = DeviceStruct()
# Device info
self.struct.add_swap_string_field("device_type", 110, 6)
self.struct.add_sn_field("serial_number", 116)
# Power IO
# self.struct.add_uint_field('dc_input_power', ?)
# self.struct.add_uint_field('ac_input_power', ?)
# self.struct.add_uint_field('ac_output_power', ?)
# self.struct.add_uint_field('dc_output_power', ?)
# Power IO statistics
self.struct.add_decimal_field(
"power_generation", 154, 1
) # Total power generated since last reset (kwh)
# Battery
self.struct.add_uint_field("total_battery_percent", 102)
# Battery packs
# self.struct.add_uint_field('pack_num_max', ?) # internal
# self.struct.add_decimal_field('total_battery_voltage', ?, 1)
# self.struct.add_uint_field('pack_num', ?) # internal
# self.struct.add_decimal_field('pack_voltage', ?, 2) # Full pack voltage
# self.struct.add_uint_field('pack_battery_percent', ?)
# Output state
# self.struct.add_bool_field('ac_output_on', ?)
# self.struct.add_bool_field('dc_output_on', ?)
# Pack selector
# self.struct.add_uint_field('pack_num', ?) # internal
# Output controls
# self.struct.add_bool_field('ac_output_on_switch', ?)
# self.struct.add_bool_field('dc_output_on_switch', ?)
# Add battery fields here?
super().__init__(address, type, sn)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return [
ReadHoldingRegisters(110, 6),
ReadHoldingRegisters(116, 4),
ReadHoldingRegisters(154, 1),
ReadHoldingRegisters(102, 1),
]
@property
def writable_ranges(self) -> List[range]:
return []
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return []
@@ -0,0 +1,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 = []
_LOGGER.info("Polling commands: " + ",".join([f"{c.starting_address}-{c.starting_address + c.quantity - 1}" for c in polling_commands]))
_LOGGER.info("Pack comands: " + ",".join([f"{c.starting_address}-{c.starting_address + c.quantity - 1}" for c in 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:
raise e # pass exception on max_retries attempt
else:
_LOGGER.warning(
f"Connect unsucessful (attempt {attempt}): {e}. Retrying..."
)
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
# Execute polling commands
for command in polling_commands:
try:
body = command.parse_response(
await self._async_send_command(command)
)
_LOGGER.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
_LOGGER.warning("Got a parse exception")
# Execute pack polling commands
if len(pack_commands) > 0 and len(self.bluetti_device.pack_num_field) == 1:
_LOGGER.debug("Polling battery packs")
for pack in range(1, self.bluetti_device.pack_num_max + 1):
_LOGGER.debug("Setting pack_num to %i", pack)
# Set current pack number
command = self.bluetti_device.build_setter_command(
"pack_num", pack
)
body = command.parse_response(
await self._async_send_command(command)
)
_LOGGER.debug("Raw data set: %s", body)
# Check set pack_num
set_pack = int.from_bytes(body, byteorder='big')
if set_pack is not pack:
_LOGGER.warning("Pack polling failed (pack_num %i doesn't match expected %i)", set_pack, pack)
continue
if self.bluetti_device.pack_num_max > 1:
# We need to wait after switching packs
# for the data to be available
await asyncio.sleep(5)
for command in pack_commands:
# Request & parse result for each pack
try:
body = command.parse_response(
await self._async_send_command(command)
)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
_LOGGER.debug("Parsed data: %s", parsed)
for key, value in parsed.items():
# Ignore likely unavailable pack data
if value != 0:
parsed_data.update({key + str(pack): value})
except ParseError:
_LOGGER.warning("Got a parse exception...")
except TimeoutError as err:
_LOGGER.error(f"Polling timed out ({self.polling_timeout}s). Trying again later", exc_info=err)
return None
except BleakError as err:
_LOGGER.error("Bleak error: %s", err)
return None
finally:
# Disconnect if connection not persistant
if not self.persistent_conn:
if self.has_notifier:
try:
await self.client.stop_notify(NOTIFY_UUID)
except:
# Ignore errors here
pass
self.has_notifier = False
await self.client.disconnect()
# Check if dict is empty
if not parsed_data:
return None
return parsed_data
async def _async_send_command(self, command: ReadHoldingRegisters) -> bytes:
"""Send command and return response"""
try:
# Prepare to make request
self.current_command = command
self.notify_future = self.create_future()
self.notify_response = bytearray()
# Make request
_LOGGER.debug("Requesting %s", command)
await self.client.write_gatt_char(WRITE_UUID, bytes(command))
# Wait for response
res = await asyncio.wait_for(self.notify_future, timeout=RESPONSE_TIMEOUT)
# Process data
_LOGGER.debug("Got %s bytes", len(res))
return cast(bytes, res)
except TimeoutError:
_LOGGER.debug("Polling single command timed out")
except ModbusError as err:
_LOGGER.debug(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
# Ignore other errors
pass
# caught an exception, return empty bytes object
return bytes()
def _notification_handler(self, _sender: int, data: bytearray):
"""Handle bt data."""
# Ignore notifications we don't expect
if self.notify_future is None or self.notify_future.done():
_LOGGER.warning("Unexpected notification")
return
# If something went wrong, we might get weird data.
if data == b"AT+NAME?\r" or data == b"AT+ADV?\r":
err = BadConnectionError("Got AT+ notification")
self.notify_future.set_exception(err)
return
# Save data
self.notify_response.extend(data)
if len(self.notify_response) == self.current_command.response_size():
if self.current_command.is_valid_response(self.notify_response):
self.notify_future.set_result(self.notify_response)
else:
self.notify_future.set_exception(ParseError("Failed checksum"))
elif self.current_command.is_exception_response(self.notify_response):
# We got a MODBUS command exception
msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}"
self.notify_future.set_exception(ModbusError(msg))
@@ -0,0 +1,59 @@
"""Device recognizer."""
import asyncio
import logging
from typing import Any, Callable
from bleak import BleakClient
from custom_components.bluetti_bt.bluetti_bt_lib.utils.commands import (
ReadHoldingRegisters,
)
from ..base_devices.ProtocolV2Device import ProtocolV2Device
from ..bluetooth.device_reader import DeviceReader
_LOGGER = logging.getLogger(__name__)
async def recognize_device(
bleak_client: BleakClient,
future_builder_method: Callable[[], asyncio.Future[Any]],
) -> str:
# Since we don't know the type we use the base device
bluetti_device = ProtocolV2Device("Unknown", "Unknown", "Unknown")
# Create device builder
device_reader = DeviceReader(bleak_client, bluetti_device, future_builder_method)
# Retry a few times to get data
for _ in range(1, 50):
# We only need 6 registers to get the device type
data = await device_reader.read_data(
[
ReadHoldingRegisters(110, 6),
]
)
if data is not None:
break
await asyncio.sleep(0.1)
if data is None:
# Should not happen
return "Unknown"
field_data = data.get("device_type")
if field_data is None:
# We have a problem
_LOGGER.error("No data in device type field_datas. Maybe it's a V1 device?")
return "Unknown"
if not isinstance(field_data, str):
# We have a problem
_LOGGER.error("Invalid data in device type field_datas")
return "Unknown"
return field_data
@@ -0,0 +1,6 @@
"""Const definitions."""
RESPONSE_TIMEOUT = 5
WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb"
NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
DEVICE_NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb"
@@ -0,0 +1,8 @@
"""AC180 fields."""
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC180(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC180", sn)
@@ -0,0 +1,52 @@
"""AC180P fields."""
from typing import List
from ..field_enums import ChargingMode
from ..utils.commands import ReadHoldingRegisters
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC180P(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC180P", sn)
# Power IO
self.struct.add_uint_field('dc_output_power', 140)
self.struct.add_uint_field('ac_output_power', 142)
self.struct.add_uint_field('dc_input_power', 144)
self.struct.add_uint_field('ac_input_power', 146)
# Input Details (1100 - 1300)
self.struct.add_decimal_field('ac_input_voltage', 1314, 1)
# Controls (2000)
self.struct.add_bool_field('ac_output_on_switch', 2011)
self.struct.add_bool_field('dc_output_on_switch', 2012)
self.struct.add_bool_field('silent_charging_on', 2020) # to be able to choose between normal and silent charging modes
self.struct.add_enum_field('charging_mode', 2020, ChargingMode) # can't be set from HA, custom switch needed
self.struct.add_bool_field('power_lifting_on', 2021)
# Controls (2200)
self.struct.add_bool_field('grid_enhancement_mode_on', 2225)
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [
ReadHoldingRegisters(140, 1),
ReadHoldingRegisters(142, 1),
ReadHoldingRegisters(144, 1),
ReadHoldingRegisters(146, 1),
ReadHoldingRegisters(1314, 1),
ReadHoldingRegisters(2011, 1),
ReadHoldingRegisters(2012, 1),
ReadHoldingRegisters(2020, 1),
ReadHoldingRegisters(2021, 1),
ReadHoldingRegisters(2225, 1),
]
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(2000, 2022),
range(2200, 2226)
]
@@ -0,0 +1,56 @@
"""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, multiplier=10)
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, multiplier=10)
self.struct.add_uint_field("internal_dc_input_voltage", 86, multiplier=.1)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1, multiplier=10)
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, multiplier=.1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls
self.struct.add_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 self.struct.get_read_holding_registers(
filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
)
@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, 37),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,56 @@
"""AC200M fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC200M(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC200M", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# 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 self.struct.get_read_holding_registers(
filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
)
@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, 37),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,73 @@
"""AC300 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC300(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC300", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_int_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_int_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_int_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("pv_input_voltage1", 86, multiplier=.1)
self.struct.add_decimal_field("pv_input_power1", 87, 1, multiplier=10)
self.struct.add_decimal_field("pv_input_current1", 88, 2, multiplier=10)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode)
self.struct.add_bool_field("split_phase_on", 3004)
self.struct.add_enum_field(
"split_phase_machine_mode", 3005, SplitPhaseMachineType
)
self.struct.add_bool_field("grid_charge_on", 3011)
self.struct.add_bool_field("time_control_on", 3013)
self.struct.add_uint_field("battery_range_start", 3015)
self.struct.add_uint_field("battery_range_end", 3016)
self.struct.add_enum_field("auto_sleep_mode", 3061, AutoSleepMode)
@property
def pack_num_max(self):
return 4
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return self.struct.get_read_holding_registers(
filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
)
@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, 37),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,73 @@
"""AC500 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class AC500(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC500", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_int_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_int_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_int_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("pv_input_voltage1", 86, multiplier=.1)
self.struct.add_decimal_field("pv_input_power1", 87, 1, multiplier=10)
self.struct.add_decimal_field("pv_input_current1", 88, 2, multiplier=10)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
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 6
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
return self.struct.get_read_holding_registers(
filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
)
@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, 37),
ReadHoldingRegisters(201, 2),
]
@@ -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,8 @@
"""AC70 fields."""
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC70(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC70", sn)
@@ -0,0 +1,8 @@
"""AC70P fields."""
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC70P(ProtocolV2Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "AC70P", sn)
@@ -0,0 +1,36 @@
"""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 self.struct.get_read_holding_registers()
@property
def writable_ranges(self) -> List[range]:
return super().writable_ranges + [
range(3034, 3035),
range(3060, 3061),
range(3063, 3067),
]
@@ -0,0 +1,69 @@
"""EP500 fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EP500(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP500", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
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 polling_commands(self) -> List[ReadHoldingRegisters]:
return self.struct.get_read_holding_registers(
filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
)
@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, 37),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,69 @@
"""EP500P fields."""
from typing import List
from ..utils.commands import ReadHoldingRegisters
from ..field_enums import AutoSleepMode, OutputMode, SplitPhaseMachineType, UpsMode
from ..base_devices.ProtocolV1Device import ProtocolV1Device
class EP500P(ProtocolV1Device):
def __init__(self, address: str, sn: str):
super().__init__(address, "EP500P", sn)
# Details
self.struct.add_enum_field("ac_output_mode", 70, OutputMode)
self.struct.add_decimal_field("internal_ac_voltage", 71, 1)
self.struct.add_decimal_field("internal_current_one", 72, 1)
self.struct.add_uint_field("internal_power_one", 73)
self.struct.add_decimal_field("internal_ac_frequency", 74, 2)
self.struct.add_decimal_field("internal_current_two", 75, 1)
self.struct.add_uint_field("internal_power_two", 76)
self.struct.add_decimal_field("ac_input_voltage", 77, 1)
self.struct.add_decimal_field("internal_current_three", 78, 1, (0, 100))
self.struct.add_uint_field("internal_power_three", 79)
self.struct.add_decimal_field("ac_input_frequency", 80, 2)
self.struct.add_uint_field("internal_dc_input_voltage", 86)
self.struct.add_decimal_field("internal_dc_input_power", 87, 1)
self.struct.add_decimal_field("internal_dc_input_current", 88, 2)
# Battery packs
self.struct.add_uint_field("pack_num_max", 91) # internal
self.struct.add_decimal_field("total_battery_voltage", 92, 1)
self.struct.add_uint_field("pack_num_result", 96) # internal
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
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 polling_commands(self) -> List[ReadHoldingRegisters]:
return self.struct.get_read_holding_registers(
filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
)
@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, 37),
ReadHoldingRegisters(201, 2),
]
@@ -0,0 +1,100 @@
"""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 self.struct.get_read_holding_registers()
@@ -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,356 @@
"""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"),
"dsp_version": FieldAttributes(
type=FieldType.NUMERIC,
name=f"DSP Version",
),
"arm_version": FieldAttributes(
type=FieldType.NUMERIC,
name=f"ARM Version",
),
"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",
),
"grid_enhancement_mode_on": FieldAttributes(
type=FieldType.BOOL,
setter=True,
name="Grid Enhancement Mode",
),
"silent_charging_on": FieldAttributes(
type=FieldType.BOOL,
setter=True,
name="Silent Charging",
),
}
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,64 @@
"""Device builder functions."""
import re
from ..devices.ac60 import AC60
from ..devices.ac70 import AC70
from ..devices.ac70p import AC70P
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|AC70|AC70P|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] == "AC70":
return AC70(address, match[2])
if match[1] == "AC70P":
return AC70P(address, match[2])
if match[1] == "AC180":
return AC180(address, match[2])
if match[1] == "AC180P":
return AC180P(address, match[2])
if match[1] == "AC200L":
return AC200L(address, match[2])
if match[1] == "AC200M":
return AC200M(address, match[2])
if match[1] == "AC300":
return AC300(address, match[2])
if match[1] == "AC500":
return AC500(address, match[2])
if match[1] == "EB3A":
return EB3A(address, match[2])
if match[1] == "EP500":
return EP500(address, match[2])
if match[1] == "EP500P":
return EP500P(address, match[2])
if match[1] == "EP600":
return EP600(address, match[2])
if match[1] == "EP760":
return EP760(address, match[2])
if match[1] == "EP800":
return EP800(address, match[2])
def get_type_by_bt_name(bt_name: str):
match = DEVICE_NAME_RE.match(bt_name)
if match is None:
return "Unknown"
return match[1]
@@ -0,0 +1,238 @@
"""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
from .commands import ReadHoldingRegisters
import struct
from typing import Any, List, Optional, Tuple, Type
def swap_bytes(data: bytes):
"""Swaps the place of every other byte, returning a new byte array"""
arr = bytearray(data)
for i in range(0, len(arr) - 1, 2):
arr[i], arr[i + 1] = arr[i + 1], arr[i]
return arr
class DeviceField:
def __init__(self, name: str, address: int, size: int):
self.name = name
self.address = address
self.size = size
def parse(self, data: bytes) -> Any:
raise NotImplementedError
def in_range(self, val: Any) -> bool:
return True
class UintField(DeviceField):
def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]], multiplier: float):
self.range = range
self.multiplier = multiplier
super().__init__(name, address, 1)
def parse(self, data: bytes) -> int:
val = struct.unpack("!H", data)[0]
return val * self.multiplier
def in_range(self, val: int) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class IntField(DeviceField):
def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]]):
self.range = range
super().__init__(name, address, 1)
def parse(self, data: bytes) -> int:
return struct.unpack(">h", data)[0]
def in_range(self, val: int) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class BoolField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 1)
def parse(self, data: bytes) -> bool:
return struct.unpack("!H", data)[0] == 1
class EnumField(DeviceField):
def __init__(self, name: str, address: int, enum: Type[Enum]):
self.enum = enum
super().__init__(name, address, 1)
def parse(self, data: bytes) -> Any:
val = struct.unpack("!H", data)[0]
return self.enum(val)
class DecimalField(DeviceField):
def __init__(
self, name: str, address: int, scale: int, range: Optional[Tuple[int, int]], multiplier: float
):
self.scale = scale
self.range = range
self.multiplier = multiplier
super().__init__(name, address, 1)
def parse(self, data: bytes) -> Decimal:
val = Decimal(struct.unpack("!H", data)[0])
return (val / 10 ** self.scale) * Decimal(self.multiplier)
def in_range(self, val: Decimal) -> bool:
if self.range is None:
return True
else:
return val >= self.range[0] and val <= self.range[1]
class DecimalArrayField(DeviceField):
def __init__(self, name: str, address: int, size: int, scale: int):
self.scale = scale
super().__init__(name, address, size)
def parse(self, data: bytes) -> Decimal:
values = list(struct.unpack(f"!{self.size}H", data))
return [Decimal(v) / 10 ** self.scale for v in values]
class StringField(DeviceField):
"""Fixed-width null-terminated string field"""
def parse(self, data: bytes) -> str:
return data.rstrip(b"\0").decode("ascii")
class SwapStringField(DeviceField):
"""Fixed-width null-terminated string field"""
def parse(self, data: bytes) -> str:
return swap_bytes(data).rstrip(b"\0").decode("ascii")
class VersionField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 2)
def parse(self, data: bytes) -> int:
values = struct.unpack("!2H", data)
return Decimal(values[0] + (values[1] << 16)) / 100
class SerialNumberField(DeviceField):
def __init__(self, name: str, address: int):
super().__init__(name, address, 4)
def parse(self, data: bytes) -> int:
values = struct.unpack("!4H", data)
return values[0] + (values[1] << 16) + (values[2] << 32) + (values[3] << 48)
class DeviceStruct:
fields: List[DeviceField]
def __init__(self):
self.fields = []
def add_uint_field(self, name: str, address: int, range: Tuple[int, int] = None, multiplier: float = 1):
self.fields.append(UintField(name, address, range, multiplier))
def add_int_field(self, name: str, address: int, range: Tuple[int, int] = None):
self.fields.append(IntField(name, address, range))
def add_bool_field(self, name: str, address: int):
self.fields.append(BoolField(name, address))
def add_enum_field(self, name: str, address: int, enum: Type[Enum]):
self.fields.append(EnumField(name, address, enum))
def add_decimal_field(
self, name: str, address: int, scale: int, range: Tuple[int, int] = None, multiplier: float = 1
):
self.fields.append(DecimalField(name, address, scale, range, multiplier))
def add_decimal_array_field(self, name: str, address: int, size: int, scale: int):
self.fields.append(DecimalArrayField(name, address, size, scale))
def add_string_field(self, name: str, address: int, size: int):
self.fields.append(StringField(name, address, size))
def add_swap_string_field(self, name: str, address: int, size: int):
self.fields.append(SwapStringField(name, address, size))
def add_version_field(self, name: str, address: int):
self.fields.append(VersionField(name, address))
def add_sn_field(self, name: str, address: int):
self.fields.append(SerialNumberField(name, address))
def get_read_holding_registers(self, tolerance=20, filter=lambda address: True):
address_set = set()
for field in self.fields:
for address in range(field.address, field.address + field.size):
address_set.add(address)
# Filter out addresses that we specificly
# specified to exclude.
for address in set(address_set):
if not filter(address):
address_set.remove(address)
# Fast path: no addresses -> no commands.
if not address_set:
return []
commands = []
address_list = sorted(address_set)
start = address_list[0]
last = address_list[0]
for address in address_list[1:]:
# Create a new group if there's a gap
# in the addresses.
if address >= last + tolerance:
commands.append(ReadHoldingRegisters(start, last - start + 1))
start = address
last = address
commands.append(ReadHoldingRegisters(start, last - start + 1))
return commands
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
+30 -13
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import re import re
import logging import logging
from typing import Any
from bleak import BleakClient
import voluptuous as vol import voluptuous as vol
@@ -12,11 +14,13 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak, BluetoothServiceInfoBleak,
async_discovered_service_info, async_discovered_service_info,
) )
from homeassistant.const import CONF_ADDRESS, CONF_TYPE, CONF_NAME from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector from homeassistant.helpers import selector
from . import get_type_by_bt_name from .bluetti_bt_lib.utils.device_builder import get_type_by_bt_name
from .bluetti_bt_lib.bluetooth.device_recognizer import recognize_device
from .const import ( from .const import (
CONF_MAX_RETRIES, CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN, CONF_PERSISTENT_CONN,
@@ -43,6 +47,23 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle bluetooth discovery.""" """Handle bluetooth discovery."""
await self.async_set_unique_id(discovery_info.address) await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
if isinstance(discovery_info.name, str):
name = re.sub("[^A-Z0-9]+", "", discovery_info.name)
discovery_info.manufacturer_data = {
CONF_TYPE: get_type_by_bt_name(name)
}
# Get device type if needed
if isinstance(discovery_info.name, str) and discovery_info.name.startswith("PBOX"):
bleak_device = BleakClient(discovery_info.device)
device_type = await recognize_device(bleak_device, self.hass.loop.create_future)
_LOGGER.info("Device identified as %s", device_type)
discovery_info.manufacturer_data = {
CONF_TYPE: device_type.strip(),
}
discovery_info.name = discovery_info.name.replace("PBOX", device_type.strip())
self._discovery_info = discovery_info self._discovery_info = discovery_info
self.context["title_placeholders"] = {"name": discovery_info.name} self.context["title_placeholders"] = {"name": discovery_info.name}
return await self.async_step_user() return await self.async_step_user()
@@ -57,13 +78,14 @@ class BluettiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
discovery_info = self._discovered_devices[address] discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(address, raise_on_progress=False) await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
dev_type = get_type_by_bt_name(discovery_info.name) name = re.sub("[^A-Z0-9]+", "", discovery_info.name)
return self.async_create_entry( return self.async_create_entry(
title=discovery_info.name, title=name,
data={ data={
CONF_ADDRESS: discovery_info.address, CONF_ADDRESS: discovery_info.address,
CONF_TYPE: dev_type, CONF_NAME: name,
CONF_NAME: re.sub("[^A-Z0-9]+", "", discovery_info.name), CONF_TYPE: discovery_info.manufacturer_data.get(CONF_TYPE, "Unknown"),
}, },
) )
@@ -108,7 +130,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry super().__init__()
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -128,7 +150,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if user_input[CONF_MAX_RETRIES] < 1: if user_input[CONF_MAX_RETRIES] < 1:
return self.async_abort(reason="invalid_retries") return self.async_abort(reason="invalid_retries")
changed = self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
self.config_entry, self.config_entry,
data={ data={
**self.config_entry.data, **self.config_entry.data,
@@ -141,11 +163,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
}, },
}, },
) )
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( return self.async_create_entry(
title="", title="",
data={ data={
+27 -509
View File
@@ -1,7 +1,5 @@
"""Constants for the Bluetti BT integration.""" """Constants for the Bluetti BT integration."""
from bluetti_mqtt.mqtt_client import MqttFieldConfig, MqttFieldType
DOMAIN = "bluetti_bt" DOMAIN = "bluetti_bt"
MANUFACTURER = "Bluetti" MANUFACTURER = "Bluetti"
@@ -15,9 +13,27 @@ CONF_MAX_RETRIES = "max_retries"
DATA_COORDINATOR = "coordinator" DATA_COORDINATOR = "coordinator"
DATA_POLLING_RUNNING = "polling_running" DATA_POLLING_RUNNING = "polling_running"
SUPPORTED_MODELS = [
"AC60",
"AC200M",
"AC300",
"AC500",
"EB3A",
"EP500",
"EP500P",
"EP600",
"EP760",
"AC180P",
]
CONTROL_FIELDS = [ CONTROL_FIELDS = [
"ac_output_on", "ac_output_on_switch",
"dc_output_on", "dc_output_on_switch",
"power_off",
"eco_on",
"power_lifting_on",
"grid_enhancement_mode_on",
"silent_charging_on",
] ]
DIAGNOSTIC_FIELDS = [ DIAGNOSTIC_FIELDS = [
@@ -26,511 +42,13 @@ DIAGNOSTIC_FIELDS = [
"max_ac_output_power", "max_ac_output_power",
"max_ac_output_current", "max_ac_output_current",
"bcu_version", "bcu_version",
"dsp_version",
"arm_version",
"safety_module_version", "safety_module_version",
"high_voltage_module_version", "high_voltage_module_version",
"power_generation",
"total_ac_consumption",
"total_grid_consumption",
"total_grid_feed",
"ac_input_voltage",
] ]
ADDITIONAL_DEVICE_FIELDS = {
"max_ac_input_power": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Input Power per Phase",
},
),
"max_ac_input_current": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Input Current per Phase",
},
),
"max_ac_output_power": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Output Power per Phase",
},
),
"max_ac_output_current": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Max AC Output Current per Phase",
},
),
"bcu_version": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={"name": "BCU Version"},
),
"safety_module_version": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={"name": "Safety Module Version"},
),
"high_voltage_module_version": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={"name": "High Voltage Safety Module Version"},
),
'pv_input_power1': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Power 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_voltage1': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Voltage 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_current1': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Current 1",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_power2': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Power 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_voltage2': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Voltage 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
'pv_input_current2': MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Solar Input Current 2",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_power_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Power Phase 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_power_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Power Phase 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_power_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Power Phase 3",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_voltage_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Voltage Phase 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_voltage_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Voltage Phase 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"adl400_ac_input_voltage_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "ADL400 AC Input Voltage Phase 3",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_voltage_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Voltage Phase 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_voltage_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Voltage Phase 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_voltage_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Voltage Phase 3",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_current_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Current Phase 1",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_current_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Current Phase 2",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_current_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Current Phase 3",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_power_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Power Phase 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_power_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Power Phase 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"grid_input_power_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Grid Input Power Phase 3",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_frequency": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Frequency",
"unit_of_measurement": "Hz",
"device_class": "frequency",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_voltage_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Voltage Phase 1",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_voltage_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Voltage Phase 2",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_voltage_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Voltage Phase 3",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_power_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Power Phase 1",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_power_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Power Phase 2",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_power_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Power Phase 3",
"unit_of_measurement": "W",
"device_class": "power",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_current_phase1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Current Phase 1",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_current_phase2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Current Phase 2",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"ac_output_current_phase3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "AC Output Current Phase 3",
"unit_of_measurement": "A",
"device_class": "current",
"state_class": "measurement",
"force_update": True,
},
),
"testing0": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 0",
},
),
"testing1": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 1",
},
),
"testing2": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 2",
},
),
"testing3": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 3",
},
),
"testing4": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 4",
},
),
"testing5": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 5",
},
),
"testing6": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 6",
},
),
"testing7": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 7",
},
),
"testing8": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 8",
},
),
"testing9": MqttFieldConfig(
type=MqttFieldType.NUMERIC,
setter=False,
advanced=False,
home_assistant_extra={
"name": "Register Testing 9",
},
),
}
+19 -284
View File
@@ -1,14 +1,11 @@
"""Coordinator for Bluetti integration.""" """Coordinator for Bluetti integration."""
from __future__ import annotations from __future__ import annotations
from typing import List, cast
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import async_timeout
from bleak import BleakClient, BleakError from bleak import BleakClient
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -16,115 +13,14 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator, DataUpdateCoordinator,
) )
from bluetti_mqtt.bluetooth.client import BluetoothClient from .bluetti_bt_lib.bluetooth.device_reader import DeviceReader
from bluetti_mqtt.bluetooth import ( from .bluetti_bt_lib.utils.device_builder import build_device
BadConnectionError,
ModbusError,
ParseError,
build_device,
)
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice
from bluetti_mqtt.core.commands import ReadHoldingRegisters
from .const import DATA_POLLING_RUNNING, DOMAIN
from .utils import mac_loggable from .utils import mac_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class DummyDevice(BluettiDevice):
"""Dummy device used to add more fields to existing devices.
Changes made here are only temporary and should also be
contributed to https://github.com/warhammerkid/bluetti_mqtt
"""
def __init__(self, device: BluettiDevice):
"""Init dummy device with real device."""
self.struct = device.struct
if device.type == "EP600":
# DC Solar Input (copied from PR https://github.com/warhammerkid/bluetti_mqtt/pull/87 by KM011092)
self.struct.add_uint_field('pv_input_power1', 1212) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage1', 1213) # MPP 1 in - value * 0.1
#self.struct.add_uint_field('pv_input_current1', 1214) # MPP 1 in
self.struct.add_uint_field('pv_input_power2', 1220) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_voltage2', 1221) # MPP 2 in - value * 0.1
#self.struct.add_uint_field('pv_input_current2', 1222) # MPP 2 in
# ADL400 Smart Meter for AC Solar
self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228)
self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236)
self.struct.add_uint_field("adl400_ac_input_power_phase3", 1244)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase1", 1229, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase2", 1237, 1)
self.struct.add_decimal_field("adl400_ac_input_voltage_phase3", 1245, 1)
# Grid Input
self.struct.add_decimal_field("grid_input_frequency", 1300, 1)
self.struct.add_uint_field("grid_input_power_phase1", 1313)
self.struct.add_uint_field("grid_input_power_phase2", 1319)
self.struct.add_uint_field("grid_input_power_phase3", 1325)
self.struct.add_decimal_field("grid_input_voltage_phase1", 1314, 1)
self.struct.add_decimal_field("grid_input_voltage_phase2", 1320, 1)
self.struct.add_decimal_field("grid_input_voltage_phase3", 1326, 1)
self.struct.add_decimal_field("grid_input_current_phase1", 1315, 1)
self.struct.add_decimal_field("grid_input_current_phase2", 1321, 1)
self.struct.add_decimal_field("grid_input_current_phase3", 1327, 1)
# EP600 AC Output
self.struct.add_decimal_field("ac_output_frequency", 1500, 1)
self.struct.add_uint_field("ac_output_power_phase1", 1510)
self.struct.add_uint_field("ac_output_power_phase2", 1517)
self.struct.add_uint_field("ac_output_power_phase3", 1524)
self.struct.add_decimal_field("ac_output_voltage_phase1", 1511, 1)
self.struct.add_decimal_field("ac_output_voltage_phase2", 1518, 1)
self.struct.add_decimal_field("ac_output_voltage_phase3", 1525, 1)
self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1)
self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1)
self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1)
super().__init__(device.address, device.type, device.sn)
self._parent = device
@property
def pack_num_max(self):
return self._parent.pack_num_max
@property
def polling_commands(self) -> List[ReadHoldingRegisters]:
if self.type == "EP600":
return [
ReadHoldingRegisters(100, 62),
ReadHoldingRegisters(1228, 19),
ReadHoldingRegisters(1300, 28),
ReadHoldingRegisters(1500, 27),
ReadHoldingRegisters(2022, 6),
ReadHoldingRegisters(2213, 4),
ReadHoldingRegisters(1212, 11),
# Battery
ReadHoldingRegisters(6101, 7),
ReadHoldingRegisters(6175, 11),
]
return self._parent.polling_commands
@property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
if self.type == "EP600":
return []
return self._parent.pack_polling_commands
@property
def logging_commands(self) -> List[ReadHoldingRegisters]:
return self._parent.logging_commands
@property
def pack_logging_commands(self) -> List[ReadHoldingRegisters]:
return self._parent.pack_logging_commands
@property
def writable_ranges(self) -> List[range]:
return self._parent.writable_ranges
class PollingCoordinator(DataUpdateCoordinator): class PollingCoordinator(DataUpdateCoordinator):
"""Polling coordinator.""" """Polling coordinator."""
@@ -145,18 +41,8 @@ class PollingCoordinator(DataUpdateCoordinator):
name="Bluetti polling coordinator", name="Bluetti polling coordinator",
update_interval=timedelta(seconds=polling_interval), update_interval=timedelta(seconds=polling_interval),
) )
self._address = address
self.has_notifier = False
self.notify_future = None
self.current_command = None
self.notify_response = bytearray()
bluetti_device = build_device(address, device_name)
self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout
self.max_retries = max_retries
# Add or modify device fields self.address = address
self.bluetti_device = DummyDevice(bluetti_device)
# Create client # Create client
self.logger.debug("Creating client") self.logger.debug("Creating client")
@@ -164,10 +50,17 @@ class PollingCoordinator(DataUpdateCoordinator):
if device is None: if device is None:
self.logger.error("Device %s not available", mac_loggable(address)) self.logger.error("Device %s not available", mac_loggable(address))
return None return None
self.client = BleakClient(device) client = BleakClient(device)
bluetti_device = build_device(address, device_name)
# polling mutex to guard against switches self.reader = DeviceReader(
self.polling_lock = asyncio.Lock() client,
bluetti_device,
self.hass.loop.create_future,
persistent_conn=persistent_conn,
polling_timeout=polling_timeout,
max_retries=max_retries,
)
async def _async_update_data(self): async def _async_update_data(self):
"""Fetch data from API endpoint. """Fetch data from API endpoint.
@@ -175,169 +68,11 @@ class PollingCoordinator(DataUpdateCoordinator):
This is the place to pre-process the data to lookup tables This is the place to pre-process the data to lookup tables
so entities can quickly look up their data. so entities can quickly look up their data.
""" """
self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = True
self.logger.debug("Polling data") # Check if device is connected
if bluetooth.async_address_present(self.hass, self.address, connectable=True) is False:
if self.bluetti_device is None: self.logger.warning("Device not connected")
self.logger.error("Device type for %s not found", mac_loggable(self._address)) self.last_update_success = False
return None return None
parsed_data: dict = {} return await self.reader.read_data()
async with self.polling_lock:
try:
async with async_timeout.timeout(self.polling_timeout):
# Reconnect if not connected
for attempt in range(1, self.max_retries + 1):
try:
if not self.client.is_connected:
await self.client.connect()
break
except Exception as e:
if attempt == self.max_retries or attempt == 1:
raise # pass exception on max_retries attempt
else:
self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...")
await asyncio.sleep(2)
# Attach notifier if needed
if not self.has_notifier:
await self.client.start_notify(
BluetoothClient.NOTIFY_UUID, self._notification_handler
)
self.has_notifier = True
for command in self.bluetti_device.polling_commands:
try:
body = command.parse_response(
await self.async_send_command(command)
)
self.logger.debug("Raw data: %s", body)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
self.logger.debug("Parsed data: %s", parsed)
parsed_data.update(parsed)
except ParseError:
self.logger.warning("Got a parse exception...")
if len(self.bluetti_device.pack_polling_commands) > 0:
self.logger.debug("Polling battery packs")
# pack polling
for pack in range (1, self.bluetti_device.pack_num_max + 1):
# Set current pack number
await self.async_send_command(
self.bluetti_device.build_setter_command('pack_num', pack)
)
for command in self.bluetti_device.pack_polling_commands:
# Request & parse result for each pack
try:
body = command.parse_response(
await self.async_send_command(command)
)
parsed = self.bluetti_device.parse(
command.starting_address, body
)
self.logger.debug("Parsed data: %s", parsed)
pack_number = parsed.get('pack_num')
if not isinstance(pack_number, int) or pack_number != pack:
self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", pack_number, pack)
continue
for key, value in parsed.items():
parsed_data.update({key+str(pack):value})
except ParseError:
self.logger.warning("Got a parse exception...")
except TimeoutError:
self.logger.warning("Polling timed out for device %s", mac_loggable(self._address))
return None
except BleakError as err:
self.logger.warning("Bleak error: %s", err)
return None
finally:
# Disconnect if connection not persistant
if not self.persistent_conn:
if self.has_notifier:
await self.client.stop_notify(BluetoothClient.NOTIFY_UUID)
self.has_notifier = False
await self.client.disconnect()
self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False
# Pass data back to sensors
return parsed_data
async def async_send_command(self, command: ReadHoldingRegisters) -> bytes:
"""Send command and return response"""
try:
# Prepare to make request
self.current_command = command
self.notify_future = self.hass.loop.create_future()
self.notify_response = bytearray()
# Make request
self.logger.debug("Requesting %s", command)
await self.client.write_gatt_char(
BluetoothClient.WRITE_UUID, bytes(command)
)
# Wait for response
res = await asyncio.wait_for(
self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT
)
# Process data
self.logger.debug("Got %s bytes", len(res))
return cast(bytes, res)
except TimeoutError:
self.logger.debug(
"Polling timed out (address: %s)", mac_loggable(self._address)
)
except ModbusError as err:
self.logger.warning(
"Got an invalid request error for %s: %s",
command,
err,
)
except (BadConnectionError, BleakError) as err:
self.logger.warning(
"Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err
)
# caught an exception, return empty bytes object
return bytes()
def _notification_handler(self, _sender: int, data: bytearray):
"""Handle bt data."""
# Ignore notifications we don't expect
if not self.notify_future or self.notify_future.done():
_LOGGER.warning("Unexpected notification")
return
# If something went wrong, we might get weird data.
if data == b"AT+NAME?\r" or data == b"AT+ADV?\r":
err = BadConnectionError("Got AT+ notification")
self.notify_future.set_exception(err)
return
# Save data
self.notify_response.extend(data)
if len(self.notify_response) == self.current_command.response_size():
if self.current_command.is_valid_response(self.notify_response):
self.notify_future.set_result(self.notify_response)
else:
self.notify_future.set_exception(ParseError("Failed checksum"))
elif self.current_command.is_exception_response(self.notify_response):
# We got a MODBUS command exception
msg = f"MODBUS Exception {self.current_command}: {self.notify_response[2]}"
self.notify_future.set_exception(ModbusError(msg))
+11 -5
View File
@@ -2,21 +2,27 @@
"domain": "bluetti_bt", "domain": "bluetti_bt",
"name": "Bluetti BT", "name": "Bluetti BT",
"bluetooth": [ "bluetooth": [
{ "local_name": "AC60*" },
{ "local_name": "AC70*" },
{ "local_name": "AC180*" },
{ "local_name": "AC200L*" },
{ "local_name": "AC200M*" }, { "local_name": "AC200M*" },
{ "local_name": "AC300*" }, { "local_name": "AC300*" },
{ "local_name": "AC500*" }, { "local_name": "AC500*" },
{ "local_name": "AC60*" },
{ "local_name": "EB3A*" }, { "local_name": "EB3A*" },
{ "local_name": "EP500*" }, { "local_name": "EP500*" },
{ "local_name": "EP600*" } { "local_name": "EP600*" },
{ "local_name": "EP760*" },
{ "local_name": "EP800*" },
{ "local_name": "PBOX*" }
], ],
"codeowners": ["@Patrick762"], "codeowners": ["@Patrick762"],
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://github.com/Patrick762/hassio-bluetti-bt", "documentation": "https://github.com/Patrick762/hassio-bluetti-bt",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues",
"requirements": ["bluetti_mqtt==0.15.0"], "requirements": [],
"version": "0.0.11" "version": "0.1.5"
} }
+56 -41
View File
@@ -6,33 +6,24 @@ from enum import Enum
import logging import logging
from decimal import Decimal from decimal import Decimal
from homeassistant.components.sensor import SensorEntity, CONF_STATE_CLASS from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.const import ( from homeassistant.const import (
CONF_ADDRESS, CONF_ADDRESS,
CONF_NAME, CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_DEVICE_CLASS,
EntityCategory, EntityCategory,
) )
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity
CoordinatorEntity,
)
from bluetti_mqtt.bluetooth import build_device from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from bluetti_mqtt.mqtt_client import ( from .bluetti_bt_lib.utils.device_builder import build_device
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
battery_pack_fields,
)
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import DATA_COORDINATOR, DOMAIN, CONF_OPTIONS, DIAGNOSTIC_FIELDS, ADDITIONAL_DEVICE_FIELDS from .const import DATA_COORDINATOR, DOMAIN, DIAGNOSTIC_FIELDS
from .coordinator import PollingCoordinator, DummyDevice from .coordinator import PollingCoordinator
from .utils import unique_id_loggable from .utils import unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -54,48 +45,45 @@ async def async_setup_entry(
# Add sensors according to device_info # Add sensors according to device_info
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)
sensors_to_add = [] sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS all_fields = FIELD_ATTRIBUTES
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
if len(bluetti_device.pack_polling_commands) > 0: if len(bluetti_device.pack_polling_commands) > 0:
# add pack fields for device # add pack fields for device
_LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max) _LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max)
for pack in range (1, bluetti_device.pack_num_max + 1): for pack in range (1, bluetti_device.pack_num_max + 1):
for name, field in battery_pack_fields(pack).items(): for name, field in PACK_FIELD_ATTRIBUTES(pack).items():
all_fields.update({name+str(pack): field}) all_fields.update({name+str(pack): field})
for field_key, field_config in all_fields.items(): for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key) or field_config.id_override is not None: if bluetti_device.has_field(field_key) or (len(bluetti_device.pack_polling_commands) > 0 and field_key.startswith("pack_")):
category = None category = None
if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS: if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS:
category = EntityCategory.DIAGNOSTIC category = EntityCategory.DIAGNOSTIC
if field_config.type == MqttFieldType.NUMERIC: if field_config.type == FieldType.NUMERIC:
sensors_to_add.append( sensors_to_add.append(
BluettiSensor( BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
field_config.home_assistant_extra.get(CONF_UNIT_OF_MEASUREMENT), field_config.unit_of_measurement,
field_config.home_assistant_extra.get(CONF_DEVICE_CLASS), field_config.device_class,
field_config.home_assistant_extra.get(CONF_STATE_CLASS), field_config.state_class,
category=category, category=category,
) )
) )
elif field_config.type == MqttFieldType.ENUM: elif field_config.type == FieldType.ENUM:
sensors_to_add.append( sensors_to_add.append(
BluettiSensor( BluettiSensor(
hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR], hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR],
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
options=field_config.home_assistant_extra.get(CONF_OPTIONS), options=[o.value for o in field_config.options],
category=category, category=category,
) )
) )
@@ -122,12 +110,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._unavailable_counter = 0
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_name = name
self._attr_available = False self._attr_available = False
self._attr_unique_id = get_unique_id(e_name) self._attr_unique_id = get_unique_id(e_name)
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
@@ -136,18 +126,44 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
self._attr_entity_category = category self._attr_entity_category = category
self._options = options self._options = options
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available
def _set_available(self):
"""Set sensor as available."""
self._attr_available = True
self._unavailable_counter = 0
self._attr_extra_state_attributes = {}
self.async_write_ha_state()
def _set_unavailable(self, cause: str = "Unknown"):
"""Set sensor as unavailable."""
self._unavailable_counter += 1
self._attr_extra_state_attributes = {
"unavailable_counter": self._unavailable_counter,
"unavailable_cause": cause,
}
if self._unavailable_counter >= 5:
self._attr_available = False
self.async_write_ha_state()
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return return
if self.coordinator.data is None: if self.coordinator.data is None:
_LOGGER.warning( _LOGGER.debug(
"Data from coordinator is None. Skipping update for %s", "Data from coordinator is None",
unique_id_loggable(self._attr_unique_id)
) )
self._set_unavailable("Data is None")
return return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
@@ -155,13 +171,13 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
_LOGGER.warning( _LOGGER.warning(
"Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id) "Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False self._set_unavailable("Invalid data")
return return
response_data = self.coordinator.data.get(self._response_key) response_data = self.coordinator.data.get(self._response_key)
if response_data is None: if response_data is None:
_LOGGER.warning("No data for available for (%s)", self._response_key) _LOGGER.debug("No data for available for (%s)", self._response_key)
self._attr_available = False self._set_unavailable("No data")
return return
if ( if (
@@ -177,19 +193,18 @@ class BluettiSensor(CoordinatorEntity, SensorEntity):
response_data, response_data,
type(response_data), type(response_data),
) )
self._attr_available = False self._set_unavailable("Invalid data type")
return return
self._attr_available = True self._set_available()
# 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, Enum) and isinstance(response_data, Enum)
and response_data.value < len(self._options)
): ):
# Enum # Enum
self._attr_native_value = self._options[response_data.value] self._attr_native_value = response_data.name
else: else:
# Numeric # Numeric
self._attr_native_value = response_data self._attr_native_value = response_data
+25 -23
View File
@@ -23,18 +23,14 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
) )
from bluetti_mqtt.bluetooth import build_device from .bluetti_bt_lib.base_devices.BluettiDevice import BluettiDevice
from bluetti_mqtt.bluetooth.client import BluetoothClient from .bluetti_bt_lib.const import WRITE_UUID
from bluetti_mqtt.core.devices.bluetti_device import BluettiDevice from .bluetti_bt_lib.field_attributes import FIELD_ATTRIBUTES, PACK_FIELD_ATTRIBUTES, FieldType
from bluetti_mqtt.mqtt_client import ( from .bluetti_bt_lib.utils.device_builder import build_device
NORMAL_DEVICE_FIELDS,
DC_INPUT_FIELDS,
MqttFieldType,
)
from . import device_info as dev_info, get_unique_id from . import device_info as dev_info, get_unique_id
from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN
from .coordinator import PollingCoordinator, DummyDevice from .coordinator import PollingCoordinator
from .utils import mac_loggable, unique_id_loggable from .utils import mac_loggable, unique_id_loggable
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -56,15 +52,12 @@ async def async_setup_entry(
# Add sensors according to device_info # Add sensors according to device_info
bluetti_device = build_device(address, device_name) bluetti_device = build_device(address, device_name)
bluetti_device = DummyDevice(bluetti_device)
sensors_to_add = [] sensors_to_add = []
all_fields = NORMAL_DEVICE_FIELDS all_fields = FIELD_ATTRIBUTES
all_fields.update(DC_INPUT_FIELDS)
all_fields.update(ADDITIONAL_DEVICE_FIELDS)
for field_key, field_config in all_fields.items(): for field_key, field_config in all_fields.items():
if bluetti_device.has_field(field_key): if bluetti_device.has_field(field_key):
if field_config.type == MqttFieldType.BOOL: if field_config.type == FieldType.BOOL:
if field_config.setter is True and field_key in CONTROL_FIELDS: if field_config.setter is True and field_key in CONTROL_FIELDS:
sensors_to_add.append( sensors_to_add.append(
BluettiSwitch( BluettiSwitch(
@@ -73,7 +66,7 @@ async def async_setup_entry(
device_info, device_info,
address, address,
field_key, field_key,
field_config.home_assistant_extra.get(CONF_NAME, ""), field_config.name,
entry.entry_id entry.entry_id
) )
) )
@@ -100,38 +93,46 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
self._bluetti_device = bluetti_device self._bluetti_device = bluetti_device
self._coordinator = coordinator self._coordinator = coordinator
self._client = coordinator.client self._client = coordinator.reader.client
self._polling_lock = coordinator.polling_lock self._polling_lock = coordinator.reader.polling_lock
e_name = f"{device_info.get('name')} {name}" e_name = f"{device_info.get('name')} {name}"
self._address = address self._address = address
self._response_key = response_key self._response_key = response_key
self._entry_id = entry_id self._entry_id = entry_id
self._attr_device_info = device_info self._attr_device_info = device_info
self._attr_name = e_name self._attr_has_entity_name = True
self._attr_name = name
self._attr_available = False self._attr_available = False
self._attr_unique_id = get_unique_id(e_name) self._attr_unique_id = get_unique_id(e_name)
self._attr_entity_category = category self._attr_entity_category = category
self._attr_device_class = SwitchDeviceClass.OUTLET self._attr_device_class = SwitchDeviceClass.OUTLET
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._attr_available
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: if self.coordinator.reader.persistent_conn and not self.coordinator.reader.client.is_connected:
return return
_LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id))
if not isinstance(self.coordinator.data, dict): if not isinstance(self.coordinator.data, dict):
_LOGGER.error( _LOGGER.debug(
"Invalid data from coordinator (switch.%s)", unique_id_loggable(self._attr_unique_id) "Invalid data from coordinator (switch.%s)", unique_id_loggable(self._attr_unique_id)
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
response_data = self.coordinator.data.get(self._response_key) response_data = self.coordinator.data.get(self._response_key)
if response_data is None: if response_data is None:
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
if not isinstance(response_data, bool): if not isinstance(response_data, bool):
@@ -141,6 +142,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
response_data, response_data,
) )
self._attr_available = False self._attr_available = False
self.async_write_ha_state()
return return
self._attr_available = True self._attr_available = True
@@ -170,7 +172,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
# Send command # Send command
_LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state) _LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state)
await self._client.write_gatt_char( await self._client.write_gatt_char(
BluetoothClient.WRITE_UUID, bytes(command) WRITE_UUID, bytes(command)
) )
# Wait until device has changed value, otherwise reading register might reset it # Wait until device has changed value, otherwise reading register might reset it
@@ -184,7 +186,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity):
return None return None
finally: finally:
# Disconnect if connection not persistant # Disconnect if connection not persistant
if not self._coordinator.persistent_conn: if not self._coordinator.reader.persistent_conn:
await self._client.disconnect() await self._client.disconnect()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
@@ -1,4 +1,14 @@
{ {
"config": {
"step": {
"user": {
"data": {
"address": "Gerät",
"type": "Modell (erforderlich bei PBOX Geräten)"
}
}
}
},
"options": { "options": {
"step": { "step": {
"init": { "init": {
@@ -1,4 +1,14 @@
{ {
"config": {
"step": {
"user": {
"data": {
"address": "Device",
"type": "Model (required for PBOX devices)"
}
}
}
},
"options": { "options": {
"step": { "step": {
"init": { "init": {
+11
View File
@@ -0,0 +1,11 @@
homeassistant
bleak_retry_connector
pyserial
bluetooth_auto_recovery
crcmod
typing
asyncio
logging
bleak
async_timeout
aiousbwatcher
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Execute unittests of python library inside docker
sudo docker build --progress=plain -t bluetti-bt-tests .
+151
View File
@@ -0,0 +1,151 @@
"""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.ac70 import AC70
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac70p import AC70P
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.ac200l import AC200L
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_ac70(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC7056786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC70)
self.assertEqual(built.address, bt_addr)
def test_build_ac70p(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC70P56786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC70P)
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_ac200l(self):
bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC200L56786746478"
built = build_device(bt_addr, bt_name)
self.assertIsInstance(built, AC200L)
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()
+24
View File
@@ -0,0 +1,24 @@
"""Unittest for device struct."""
import unittest
from custom_components.bluetti_bt.bluetti_bt_lib.utils.commands import ReadHoldingRegisters
from custom_components.bluetti_bt.bluetti_bt_lib.utils.struct import DeviceStruct
class TestDeviceStruct(unittest.TestCase):
def test_get_read_holding_registers(self):
struct = DeviceStruct()
struct.add_swap_string_field("device_type", 110, 6)
struct.add_sn_field("serial_number", 116)
struct.add_decimal_field(
"power_generation", 154, 1
)
struct.add_uint_field("total_battery_percent", 102)
registers = struct.get_read_holding_registers()
self.assertGreater(len(registers), 0)
for reg in registers:
self.assertIsInstance(reg, ReadHoldingRegisters)