Compare commits

..
46 Commits
Author SHA1 Message Date
Patrick762 773a9b2205 fix requirements.txt 2025-06-17 21:28:09 +02:00
Patrick762 998362a5e6 reset keys after each connection 2025-06-17 21:13:07 +02:00
Patrick762 206d22c50b WIP add encryption handshake 2025-06-12 17:35:23 +02:00
Patrick762 e2ac4cf8f5 added option to enable encryption 2025-06-12 12:01:54 +02:00
Patrick762 ba98204ab2 updated deploy script 2025-06-12 11:12:56 +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
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
33 changed files with 612 additions and 138 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*/__pycache__/
*.pyc
.DS_Store
+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"
+2 -2
View File
@@ -20,11 +20,11 @@ After the installation, you can use this button to install the integration:
- AC70 (basic data) - AC70 (basic data)
- AC70P (untested) - AC70P (untested)
- AC180 (basic data) - AC180 (basic data)
- AC180P (basic data) - AC180P (tested)
- AC200L (untested) - AC200L (untested)
- AC200M - AC200M
- AC300 (tested) - AC300 (tested)
- AC500 - AC500 (tested)
- EB3A - EB3A
- EP500 - EP500
- EP500P - EP500P
+6 -4
View File
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .const import ( from .const import (
CONF_ENCRYPTION,
CONF_MAX_RETRIES, CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN, CONF_PERSISTENT_CONN,
CONF_POLLING_INTERVAL, CONF_POLLING_INTERVAL,
@@ -33,15 +34,16 @@ _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") _LOGGER.debug("Init Bluetti BT Integration for device %s", entry.data.get(CONF_NAME))
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)
use_encryption = entry.data.get(CONF_ENCRYPTION, False)
if address is None: if address is None:
return False return False
@@ -56,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Create coordinator for polling # Create coordinator for polling
_LOGGER.debug("Creating coordinator") _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, use_encryption)
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)
@@ -86,6 +86,7 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity):
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 = name self._attr_name = name
@@ -14,6 +14,8 @@ class ProtocolV1Device(BluettiDevice):
# Device info # Device info
self.struct.add_string_field("device_type", 10, 6) self.struct.add_string_field("device_type", 10, 6)
self.struct.add_sn_field("serial_number", 17) 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 # Power IO
self.struct.add_uint_field("dc_input_power", 36) self.struct.add_uint_field("dc_input_power", 36)
@@ -6,6 +6,8 @@ from typing import Any, Callable, List, cast
import async_timeout import async_timeout
from bleak import BleakClient, BleakError from bleak import BleakClient, BleakError
from custom_components.bluetti_bt.bluetti_bt_lib.bluetooth.encryption import BluettiEncryption, Message, MessageType
from ..base_devices.BluettiDevice import BluettiDevice from ..base_devices.BluettiDevice import BluettiDevice
from ..const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID from ..const import NOTIFY_UUID, RESPONSE_TIMEOUT, WRITE_UUID
from ..exceptions import BadConnectionError, ModbusError, ParseError from ..exceptions import BadConnectionError, ModbusError, ParseError
@@ -23,6 +25,7 @@ class DeviceReader:
persistent_conn: bool = False, persistent_conn: bool = False,
polling_timeout: int = 45, polling_timeout: int = 45,
max_retries: int = 5, max_retries: int = 5,
encrypted: bool = False,
) -> None: ) -> None:
self.client = bleak_client self.client = bleak_client
self.bluetti_device = bluetti_device self.bluetti_device = bluetti_device
@@ -30,6 +33,7 @@ class DeviceReader:
self.persistent_conn = persistent_conn self.persistent_conn = persistent_conn
self.polling_timeout = polling_timeout self.polling_timeout = polling_timeout
self.max_retries = max_retries self.max_retries = max_retries
self.encrypted = encrypted
self.has_notifier = False self.has_notifier = False
self.notify_future: asyncio.Future[Any] | None = None self.notify_future: asyncio.Future[Any] | None = None
@@ -39,6 +43,8 @@ class DeviceReader:
# polling mutex to guard against switches # polling mutex to guard against switches
self.polling_lock = asyncio.Lock() self.polling_lock = asyncio.Lock()
self.encryption = BluettiEncryption()
async def read_data( async def read_data(
self, filter_registers: List[ReadHoldingRegisters] | None = None self, filter_registers: List[ReadHoldingRegisters] | None = None
) -> dict | None: ) -> dict | None:
@@ -53,6 +59,8 @@ class DeviceReader:
if filter_registers is not None: if filter_registers is not None:
polling_commands = filter_registers polling_commands = filter_registers
pack_commands = [] 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 = {} parsed_data: dict = {}
@@ -66,8 +74,8 @@ class DeviceReader:
await self.client.connect() await self.client.connect()
break break
except Exception as e: except Exception as e:
if attempt == self.max_retries or attempt == 1: if attempt == self.max_retries:
raise # pass exception on max_retries attempt raise e # pass exception on max_retries attempt
else: else:
_LOGGER.warning( _LOGGER.warning(
f"Connect unsucessful (attempt {attempt}): {e}. Retrying..." f"Connect unsucessful (attempt {attempt}): {e}. Retrying..."
@@ -81,6 +89,10 @@ class DeviceReader:
) )
self.has_notifier = True self.has_notifier = True
while self.encrypted and not self.encryption.is_ready_for_commands:
await asyncio.sleep(5)
_LOGGER.debug("Encryption handshake not finished yet")
# Execute polling commands # Execute polling commands
for command in polling_commands: for command in polling_commands:
try: try:
@@ -117,6 +129,11 @@ class DeviceReader:
_LOGGER.warning("Pack polling failed (pack_num %i doesn't match expected %i)", set_pack, pack) _LOGGER.warning("Pack polling failed (pack_num %i doesn't match expected %i)", set_pack, pack)
continue 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: for command in pack_commands:
# Request & parse result for each pack # Request & parse result for each pack
try: try:
@@ -136,8 +153,8 @@ class DeviceReader:
except ParseError: except ParseError:
_LOGGER.warning("Got a parse exception...") _LOGGER.warning("Got a parse exception...")
except TimeoutError: except TimeoutError as err:
_LOGGER.warning("Polling timed out. Trying again later") _LOGGER.error(f"Polling timed out ({self.polling_timeout}s). Trying again later", exc_info=err)
return None return None
except BleakError as err: except BleakError as err:
_LOGGER.error("Bleak error: %s", err) _LOGGER.error("Bleak error: %s", err)
@@ -158,6 +175,9 @@ class DeviceReader:
if not parsed_data: if not parsed_data:
return None return None
# Reset Encryption keys
self.encryption.reset()
return parsed_data return parsed_data
async def _async_send_command(self, command: ReadHoldingRegisters) -> bytes: async def _async_send_command(self, command: ReadHoldingRegisters) -> bytes:
@@ -170,7 +190,16 @@ class DeviceReader:
# Make request # Make request
_LOGGER.debug("Requesting %s", command) _LOGGER.debug("Requesting %s", command)
await self.client.write_gatt_char(WRITE_UUID, bytes(command))
command_bytes = bytes(command)
# Encrypt command
if self.encrypted is True:
if not self.encryption.is_ready_for_commands:
return bytes()
command_bytes = self.encryption.aes_encrypt(command_bytes, self.encryption.secure_aes_key, None)
await self.client.write_gatt_char(WRITE_UUID, command_bytes)
# Wait for response # Wait for response
res = await asyncio.wait_for(self.notify_future, timeout=RESPONSE_TIMEOUT) res = await asyncio.wait_for(self.notify_future, timeout=RESPONSE_TIMEOUT)
@@ -194,9 +223,47 @@ class DeviceReader:
# caught an exception, return empty bytes object # caught an exception, return empty bytes object
return bytes() return bytes()
def _notification_handler(self, _sender: int, data: bytearray): async def _notification_handler(self, _sender: int, data: bytearray):
"""Handle bt data.""" """Handle bt data."""
# Handle encrypted data
if self.encrypted is True:
message = Message(data)
# Handle key exchange
if message.is_pre_key_exchange:
message.verify_checksum()
if message.type == MessageType.CHALLENGE:
challenge_response = self.encryption.msg_challenge(message)
await self.client.write_gatt_char(WRITE_UUID, challenge_response)
return
if message.type == MessageType.CHALLENGE_ACCEPTED:
_LOGGER.debug("Challenge accepted")
return
if self.encryption.unsecure_aes_key is None:
_LOGGER.error("Received encrypted message before key initialization")
key, iv = self.encryption.getKeyIv()
decrypted = Message(self.encryption.aes_decrypt(message.buffer, key, iv))
if decrypted.is_pre_key_exchange:
decrypted.verify_checksum()
if decrypted.type == MessageType.PEER_PUBKEY:
peer_pubkey_response = self.encryption.msg_peer_pubkey(decrypted)
await self.client.write_gatt_char(WRITE_UUID, peer_pubkey_response)
return
if decrypted.type == MessageType.PUBKEY_ACCEPTED:
self.encryption.msg_key_accepted(decrypted)
return
# Handle as message
data = decrypted.buffer
# Ignore notifications we don't expect # Ignore notifications we don't expect
if self.notify_future is None or self.notify_future.done(): if self.notify_future is None or self.notify_future.done():
_LOGGER.warning("Unexpected notification") _LOGGER.warning("Unexpected notification")
@@ -0,0 +1,289 @@
from enum import Enum
import hashlib
import logging
import os
import pyasn1.codec.der.decoder as der_decoder
import pyasn1.codec.der.encoder as der_encoder
from pyasn1.type import univ
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.exceptions import InvalidSignature
from custom_components.bluetti_bt.bluetti_bt_lib.const import LOCAL_AES_KEY, PRIVATE_KEY_L1, PUBLIC_KEY_K2, SECP_256R1_PUBLIC_PREFIX
_LOGGER = logging.getLogger(__name__)
KEX_MAGIC = b"**"
AES_BLOCK_SIZE = 16
def hexsum(s, sz: int):
checksum = sum(s)
as_hex = f"{checksum:0{sz*2}x}"
return bytes.fromhex(as_hex)
def hexxor(a: bytes, b: bytes) -> bytes | None:
if len(a) != len(b):
_LOGGER.error("Can only XOR two identical length byte strings")
return None
return bytes([x ^ y for x, y in zip(a, b)])
def raw_ecdsa_to_der(sig):
# <byte r[32]> <byte s[32]>
if len(sig) != 64:
raise ValueError("ecdsa signature is the wrong size")
seq = univ.SequenceOf()
seq.extend(
[
univ.Integer(int.from_bytes(sig[:32], "big")), # r
univ.Integer(int.from_bytes(sig[32:], "big")), # s
]
)
return der_encoder.encode(seq)
def der_to_raw_ecdsa(sig):
# 30 45 02 20 1956307e59448178b47c222e4e1e6c8ef7d707bc230e5a9fa77f919ec44e5f74
# | | | | |> byte r[0x20]
# | | | |---> Length
# | | |------> DER type (int)
# | |---------> Payload size
# |------------> DER type (sequence)
# 02 21 00cfad6e11abd5e803fb6874c3838bc968db1f3c070ae6b85db9d8ed8936a1eb5c
# | | |> byte s[0x21]
# | |---> Length
# |------> DER type (int)
seq, remainder = der_decoder.decode(sig)
if remainder:
raise ValueError("Found trailing data")
return b"".join([int.to_bytes(int(x), 0x20, "big") for x in seq])
def verify_and_extract_signed_data(message, signed_data_suffix: bytes | None):
# 64 bytes of data
# 64 bytes of signature
if len(message) != 128:
raise ValueError("Unexpected message length")
data = message[:64]
signature = message[64:]
signed_data = data.tobytes() + signed_data_suffix
der_signature = raw_ecdsa_to_der(signature)
try:
key_bytes = bytes.fromhex(PUBLIC_KEY_K2)
serialization.load_der_public_key(key_bytes).verify(
der_signature, signed_data, ec.ECDSA(hashes.SHA256())
)
_LOGGER.debug("Signature OK")
except InvalidSignature:
raise
return data
def pubkey_from_bytes(data):
encoded_peer_pubkey = bytes.fromhex(SECP_256R1_PUBLIC_PREFIX) + data
return serialization.load_der_public_key(encoded_peer_pubkey)
def generate_keypair():
private = ec.generate_private_key(ec.SECP256R1())
return (private.public_key(), private)
def pubkey_to_bytes(pubkey):
out = pubkey.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
if out[0] != 0x4 or len(out) != 65:
raise RuntimeError(
"First byte should be 04 for the uncompressed format, and total size 64"
)
return out[1:]
class MessageType(Enum):
CHALLENGE = 1
CHALLENGE_ACCEPTED = 3
PEER_PUBKEY = 4
PUBKEY_ACCEPTED = 6
class Message:
"""
Two types of messages. The first one is for communication that happens before we have
a proper symmetric key available. These are fully handled by this file, it's purely
to setup the encryption protocol.
2a2a .... 0000
| |> Body checksum
| |-----> Body
|----------> Magic value for pre-key-exchange
The second type is for anything after. This can either contain the type 1 message above, or
anything else -- at this point we're just wrapping the regular protocol, and we just forward
the packets dowm the regular processing path after decrypting them.
See aes_decrypt() for the format.
"""
def __init__(self, buffer: bytes):
self.buffer = buffer
self.view = memoryview(self.buffer)
@property
def header(self) -> memoryview:
return self.view[:2]
@property
def is_pre_key_exchange(self) -> bool:
return self.header == KEX_MAGIC
@property
def checksum(self) -> memoryview:
return self.view[-2:]
@property
def body(self) -> memoryview:
return self.view[len(self.header) : -len(self.checksum)]
@property
def data(self) -> memoryview:
return self.body[2:]
@property
def type(self) -> int:
return MessageType(self.body[0])
def verify_checksum(self):
message_checksum = self.checksum
computed_checksum = hexsum(self.body, len(message_checksum))
if computed_checksum != message_checksum:
_LOGGER.error("Checksum error!")
_LOGGER.debug("Checksum OK")
class BluettiEncryption:
# Derived exclusively from data sent over the network
# Used for the initial handshake
unsecure_aes_key: bytes | None = None
# Predictably derived from a seed sent by the peer
# This is the same for all the messages encrypted
# with that key during the connection
unsecure_aes_iv: bytes | None = None
# Proper key exchange gives us another key,
# that is used for the remainder of the connection
# IV is random per message
secure_aes_key: bytes | None = None
# Received through key exchange
# The signing key for the key exchange is well-known
peer_pubkey: bytes | None = None
@property
def is_ready_for_commands(self) -> bool:
return self.secure_aes_key is not None and self.peer_pubkey is not None
def aes_decrypt(self, data: bytes, aes_key: bytes | None, iv: bytes | None):
data_len = (data[0] << 8) + data[1]
if iv is None:
iv = hashlib.md5(data[2:6]).digest()
encrypted = memoryview(data)[6:]
else:
encrypted = memoryview(data[2:])
if len(encrypted) % AES_BLOCK_SIZE != 0:
raise ValueError("Data not aligned on aes block size")
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted) + decryptor.finalize()
decrypted = decrypted[:data_len]
_LOGGER.debug(">PLAIN " + decrypted.hex())
return decrypted
def aes_encrypt(self, data: bytes, aes_key: bytes | None, iv: bytes | None):
message_header = int.to_bytes(len(data), 2, "big")
if iv is None:
iv_seed = os.urandom(4)
iv = hashlib.md5(iv_seed).digest()
message_header += iv_seed
padding = (AES_BLOCK_SIZE - len(data) % AES_BLOCK_SIZE) % AES_BLOCK_SIZE
data += bytes(padding)
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
encryptor = cipher.encryptor()
encrypted = encryptor.update(data) + encryptor.finalize()
encrypted = message_header + encrypted
_LOGGER.debug("PLAIN> " + data.hex())
return encrypted
def msg_challenge(self, message: Message) -> bytes | None:
_LOGGER.debug("Received challenge")
if len(message.data) != 4:
_LOGGER.error("Unexpected message length")
return None
self.unsecure_aes_iv = hashlib.md5(message.data[::-1].tobytes()).digest()
static_key = bytes.fromhex(LOCAL_AES_KEY)
self.unsecure_aes_key = hexxor(self.unsecure_aes_iv, static_key)
_LOGGER.info("Unsecure iv " + self.unsecure_aes_iv.hex())
_LOGGER.info("Unsecure key " + self.unsecure_aes_key.hex())
body = bytes.fromhex("0204") + self.unsecure_aes_iv[8:12]
return b"".join([KEX_MAGIC, body, hexsum(body, 2)])
def msg_peer_pubkey(self, message: Message) -> bytes | None:
_LOGGER.debug("Received peer pubkey, checking signature")
data = verify_and_extract_signed_data(message.data, self.unsecure_aes_iv)
self.peer_pubkey = pubkey_from_bytes(data)
_LOGGER.debug("Generating a local keypair")
self.my_pubkey, self.my_privkey = generate_keypair()
my_pubkey_bytes = pubkey_to_bytes(self.my_pubkey)
_LOGGER.debug("Signing the local pubkey")
signing_secret = int.from_bytes(
bytes.fromhex(PRIVATE_KEY_L1), "big"
)
signing_key = ec.derive_private_key(signing_secret, ec.SECP256R1())
to_sign = my_pubkey_bytes + self.unsecure_aes_iv
signature = signing_key.sign(to_sign, ec.ECDSA(hashes.SHA256()))
raw_signature = der_to_raw_ecdsa(signature)
body = b"".join([bytes.fromhex("0580"), my_pubkey_bytes, raw_signature])
msg = b"".join([KEX_MAGIC, body, hexsum(body, 2)])
return self.aes_encrypt(msg, self.unsecure_aes_key, self.unsecure_aes_iv)
def msg_key_accepted(self, message: Message) -> None:
_LOGGER.debug("Received key exchange confirmation, calculating shared secret")
if len(message.data) != 1:
raise ValueError("Unexpected message length")
if message.data[0] != 0:
raise ValueError("Key acceptance response is not 0")
self.secure_aes_key = self.my_privkey.exchange(ec.ECDH(), self.peer_pubkey)
_LOGGER.info("Secure key " + self.secure_aes_key.hex())
def getKeyIv(self):
return (
(self.unsecure_aes_key, self.unsecure_aes_iv)
if self.secure_aes_key is None
else (self.secure_aes_key, None)
)
def reset(self):
self.peer_pubkey = None
self.secure_aes_key = None
@@ -4,3 +4,8 @@ RESPONSE_TIMEOUT = 5
WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb" WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb"
NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
DEVICE_NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb" DEVICE_NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb"
LOCAL_AES_KEY = "459FC535808941F17091E0993EE3E93D"
PRIVATE_KEY_L1 = "4F19A16E3E87BDD9BD24D3E5495B88041511943CBC8B969ADE9641D0F56AF337"
PUBLIC_KEY_K2 = "3059301306072a8648ce3d020106082a8648ce3d03010703420004A73ABF5D2232C8C1C72E68304343C272495E3A8FD6F30EA96DE2F4B3CE60B251EE21AC667CF8A71E18B46B664EAEFFE3C489F24F695B6411DB7E22CCC85A8594"
SECP_256R1_PUBLIC_PREFIX = "3059301306072a8648ce3d020106082a8648ce3d03010703420004"
@@ -1,8 +1,52 @@
"""AC180P fields.""" """AC180P fields."""
from ..base_devices.ProtocolV2Device import ProtocolV2Device from typing import List
from ..field_enums import ChargingMode
from ..utils.commands import ReadHoldingRegisters
from ..base_devices.ProtocolV2Device import ProtocolV2Device
class AC180P(ProtocolV2Device): class AC180P(ProtocolV2Device):
def __init__(self, address: str, sn: str): def __init__(self, address: str, sn: str):
super().__init__(address, "AC180P", sn) 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)
]
@@ -28,6 +28,7 @@ class AC200L(ProtocolV1Device):
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99) self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls # Controls
self.struct.add_bool_field("power_off", 3060) self.struct.add_bool_field("power_off", 3060)
@@ -39,12 +40,9 @@ class AC200L(ProtocolV1Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers(
ReadHoldingRegisters(70, 5), filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
ReadHoldingRegisters(86, 3), )
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3060, 2),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -53,8 +51,6 @@ class AC200L(ProtocolV1Device):
@property @property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]: def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [ return [
ReadHoldingRegisters(91, 2), ReadHoldingRegisters(91, 37),
ReadHoldingRegisters(96, 1), ReadHoldingRegisters(201, 2),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16)
] ]
@@ -28,6 +28,7 @@ class AC200M(ProtocolV1Device):
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99) self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls # Controls
self.struct.add_bool_field("power_off", 3060) self.struct.add_bool_field("power_off", 3060)
@@ -39,12 +40,9 @@ class AC200M(ProtocolV1Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers(
ReadHoldingRegisters(70, 5), filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
ReadHoldingRegisters(86, 3), )
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3060, 2),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -53,8 +51,6 @@ class AC200M(ProtocolV1Device):
@property @property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]: def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [ return [
ReadHoldingRegisters(91, 2), ReadHoldingRegisters(91, 37),
ReadHoldingRegisters(96, 1), ReadHoldingRegisters(201, 2),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16)
] ]
@@ -54,17 +54,9 @@ class AC300(ProtocolV1Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers(
ReadHoldingRegisters(70, 11), filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
ReadHoldingRegisters(86, 3), )
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -76,9 +68,6 @@ class AC300(ProtocolV1Device):
@property @property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]: def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [ return [
ReadHoldingRegisters(91, 2), ReadHoldingRegisters(91, 37),
ReadHoldingRegisters(96, 1),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
ReadHoldingRegisters(201, 2), ReadHoldingRegisters(201, 2),
] ]
@@ -34,6 +34,7 @@ class AC500(ProtocolV1Device):
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99) self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field('pack_bms_version', 201)
# Controls # Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode) self.struct.add_enum_field("ups_mode", 3001, UpsMode)
@@ -49,21 +50,13 @@ class AC500(ProtocolV1Device):
@property @property
def pack_num_max(self): def pack_num_max(self):
return 4 return 6
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers(
ReadHoldingRegisters(70, 11), filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
ReadHoldingRegisters(86, 3), )
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -75,8 +68,6 @@ class AC500(ProtocolV1Device):
@property @property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]: def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [ return [
ReadHoldingRegisters(91, 2), ReadHoldingRegisters(91, 37),
ReadHoldingRegisters(96, 1), ReadHoldingRegisters(201, 2),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
] ]
@@ -25,13 +25,7 @@ class EB3A(ProtocolV1Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers()
ReadHoldingRegisters(77, 1),
ReadHoldingRegisters(86, 1),
ReadHoldingRegisters(3034, 1),
ReadHoldingRegisters(3060, 1),
ReadHoldingRegisters(3063, 5),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -34,6 +34,7 @@ class EP500(ProtocolV1Device):
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99) self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls # Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode) self.struct.add_enum_field("ups_mode", 3001, UpsMode)
@@ -49,17 +50,9 @@ class EP500(ProtocolV1Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers(
ReadHoldingRegisters(70, 11), filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
ReadHoldingRegisters(86, 3), )
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -71,8 +64,6 @@ class EP500(ProtocolV1Device):
@property @property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]: def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [ return [
ReadHoldingRegisters(91, 2), ReadHoldingRegisters(91, 37),
ReadHoldingRegisters(96, 1), ReadHoldingRegisters(201, 2),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
] ]
@@ -34,6 +34,7 @@ class EP500P(ProtocolV1Device):
self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage self.struct.add_decimal_field("pack_voltage", 98, 2) # Full pack voltage
self.struct.add_uint_field("pack_battery_percent", 99) self.struct.add_uint_field("pack_battery_percent", 99)
self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal self.struct.add_decimal_array_field("cell_voltages", 105, 16, 2) # internal
self.struct.add_version_field("pack_bms_version", 201)
# Controls # Controls
self.struct.add_enum_field("ups_mode", 3001, UpsMode) self.struct.add_enum_field("ups_mode", 3001, UpsMode)
@@ -49,17 +50,9 @@ class EP500P(ProtocolV1Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers(
ReadHoldingRegisters(70, 11), filter=lambda address: address <= 90 or address >= 256 # pack_polling_commands
ReadHoldingRegisters(86, 3), )
ReadHoldingRegisters(92, 1),
ReadHoldingRegisters(3001, 1),
ReadHoldingRegisters(3004, 2),
ReadHoldingRegisters(3011, 1),
ReadHoldingRegisters(3013, 1),
ReadHoldingRegisters(3015, 2),
ReadHoldingRegisters(3061, 1),
]
@property @property
def writable_ranges(self) -> List[range]: def writable_ranges(self) -> List[range]:
@@ -71,8 +64,6 @@ class EP500P(ProtocolV1Device):
@property @property
def pack_polling_commands(self) -> List[ReadHoldingRegisters]: def pack_polling_commands(self) -> List[ReadHoldingRegisters]:
return [ return [
ReadHoldingRegisters(91, 2), ReadHoldingRegisters(91, 37),
ReadHoldingRegisters(96, 1), ReadHoldingRegisters(201, 2),
ReadHoldingRegisters(98, 2),
ReadHoldingRegisters(105, 16),
] ]
@@ -97,28 +97,4 @@ class EP600(ProtocolV2Device):
@property @property
def polling_commands(self) -> List[ReadHoldingRegisters]: def polling_commands(self) -> List[ReadHoldingRegisters]:
return super().polling_commands + [ return self.struct.get_read_holding_registers()
ReadHoldingRegisters(2022, 2),
ReadHoldingRegisters(2213, 5),
ReadHoldingRegisters(1212, 3),
ReadHoldingRegisters(1220, 3),
ReadHoldingRegisters(1228, 3),
ReadHoldingRegisters(1236, 3),
ReadHoldingRegisters(1244, 3),
ReadHoldingRegisters(1300, 1),
ReadHoldingRegisters(1313, 3),
ReadHoldingRegisters(1319, 3),
ReadHoldingRegisters(1325, 3),
ReadHoldingRegisters(1500, 1),
ReadHoldingRegisters(1510, 3),
ReadHoldingRegisters(1517, 3),
ReadHoldingRegisters(1524, 3),
ReadHoldingRegisters(152, 1),
ReadHoldingRegisters(156, 1),
ReadHoldingRegisters(158, 1),
# KM - Consumption
ReadHoldingRegisters(1430, 15),
# KM - Totals
ReadHoldingRegisters(142, 5),
]
@@ -125,6 +125,14 @@ FIELD_ATTRIBUTES: dict[str, FieldAttributes] = {
"ac_input_power": PowerFieldAttributes("AC Input Power"), "ac_input_power": PowerFieldAttributes("AC Input Power"),
"ac_output_power": PowerFieldAttributes("AC Output Power"), "ac_output_power": PowerFieldAttributes("AC Output Power"),
"dc_output_power": PowerFieldAttributes("DC 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"), "power_generation": EnergyFieldAttributes("Total Power Generation"),
"total_battery_percent": FieldAttributes( "total_battery_percent": FieldAttributes(
type=FieldType.NUMERIC, type=FieldType.NUMERIC,
@@ -316,6 +324,16 @@ FIELD_ATTRIBUTES: dict[str, FieldAttributes] = {
setter=True, setter=True,
name="Power Lifting", 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",
),
} }
@@ -4,6 +4,7 @@
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from .commands import ReadHoldingRegisters
import struct import struct
from typing import Any, List, Optional, Tuple, Type from typing import Any, List, Optional, Tuple, Type
@@ -179,6 +180,35 @@ class DeviceStruct:
def add_sn_field(self, name: str, address: int): def add_sn_field(self, name: str, address: int):
self.fields.append(SerialNumberField(name, address)) 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: def parse(self, starting_address: int, data: bytes) -> dict:
# Offsets and size are counted in 2 byte chunks, so for the range we # Offsets and size are counted in 2 byte chunks, so for the range we
+8 -6
View File
@@ -22,6 +22,7 @@ from .bluetti_bt_lib.utils.device_builder import get_type_by_bt_name
from .bluetti_bt_lib.bluetooth.device_recognizer import recognize_device from .bluetti_bt_lib.bluetooth.device_recognizer import recognize_device
from .const import ( from .const import (
CONF_ENCRYPTION,
CONF_MAX_RETRIES, CONF_MAX_RETRIES,
CONF_PERSISTENT_CONN, CONF_PERSISTENT_CONN,
CONF_POLLING_INTERVAL, CONF_POLLING_INTERVAL,
@@ -150,7 +151,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,
@@ -160,14 +161,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL], CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL],
CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT], CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT],
CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES], CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES],
CONF_ENCRYPTION: user_input[CONF_ENCRYPTION],
}, },
}, },
) )
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={
@@ -176,6 +173,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL], CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL],
CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT], CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT],
CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES], CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES],
CONF_ENCRYPTION: user_input[CONF_ENCRYPTION],
}, },
) )
@@ -203,6 +201,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_MAX_RETRIES, CONF_MAX_RETRIES,
default=self.config_entry.data.get(CONF_MAX_RETRIES, 5), default=self.config_entry.data.get(CONF_MAX_RETRIES, 5),
): int, ): int,
vol.Required(
CONF_ENCRYPTION,
default=self.config_entry.data.get(CONF_ENCRYPTION, False),
): selector.BooleanSelector(),
} }
), ),
) )
+7
View File
@@ -9,6 +9,7 @@ CONF_PERSISTENT_CONN = "persistent_conn"
CONF_POLLING_INTERVAL = "polling_interval" CONF_POLLING_INTERVAL = "polling_interval"
CONF_POLLING_TIMEOUT = "polling_timeout" CONF_POLLING_TIMEOUT = "polling_timeout"
CONF_MAX_RETRIES = "max_retries" CONF_MAX_RETRIES = "max_retries"
CONF_ENCRYPTION = "use_encryption"
DATA_COORDINATOR = "coordinator" DATA_COORDINATOR = "coordinator"
DATA_POLLING_RUNNING = "polling_running" DATA_POLLING_RUNNING = "polling_running"
@@ -23,6 +24,7 @@ SUPPORTED_MODELS = [
"EP500P", "EP500P",
"EP600", "EP600",
"EP760", "EP760",
"AC180P",
] ]
CONTROL_FIELDS = [ CONTROL_FIELDS = [
@@ -31,6 +33,8 @@ CONTROL_FIELDS = [
"power_off", "power_off",
"eco_on", "eco_on",
"power_lifting_on", "power_lifting_on",
"grid_enhancement_mode_on",
"silent_charging_on",
] ]
DIAGNOSTIC_FIELDS = [ DIAGNOSTIC_FIELDS = [
@@ -39,10 +43,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", "power_generation",
"total_ac_consumption", "total_ac_consumption",
"total_grid_consumption", "total_grid_consumption",
"total_grid_feed", "total_grid_feed",
"ac_input_voltage",
] ]
@@ -33,6 +33,7 @@ class PollingCoordinator(DataUpdateCoordinator):
persistent_conn: bool, persistent_conn: bool,
polling_timeout: int, polling_timeout: int,
max_retries: int, max_retries: int,
encrypted: bool,
): ):
"""Initialize coordinator.""" """Initialize coordinator."""
super().__init__( super().__init__(
@@ -60,6 +61,7 @@ class PollingCoordinator(DataUpdateCoordinator):
persistent_conn=persistent_conn, persistent_conn=persistent_conn,
polling_timeout=polling_timeout, polling_timeout=polling_timeout,
max_retries=max_retries, max_retries=max_retries,
encrypted=encrypted,
) )
async def _async_update_data(self): async def _async_update_data(self):
+2 -1
View File
@@ -3,6 +3,7 @@
"name": "Bluetti BT", "name": "Bluetti BT",
"bluetooth": [ "bluetooth": [
{ "local_name": "AC60*" }, { "local_name": "AC60*" },
{ "local_name": "AC70*" },
{ "local_name": "AC180*" }, { "local_name": "AC180*" },
{ "local_name": "AC200L*" }, { "local_name": "AC200L*" },
{ "local_name": "AC200M*" }, { "local_name": "AC200M*" },
@@ -23,5 +24,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues",
"requirements": [], "requirements": [],
"version": "0.1.2" "version": "0.1.4"
} }
@@ -17,7 +17,8 @@
"persistent_conn": "Dauerhafte Verbindung (Neustart erforderlich)", "persistent_conn": "Dauerhafte Verbindung (Neustart erforderlich)",
"polling_interval": "Datenabruf-Intervall in Sekunden (Neustart erforderlich)", "polling_interval": "Datenabruf-Intervall in Sekunden (Neustart erforderlich)",
"polling_timeout": "Datenabruf-Timeout in Sekunden (Neustart erforderlich)", "polling_timeout": "Datenabruf-Timeout in Sekunden (Neustart erforderlich)",
"max_retries": "Maximale Verbindungsversuche (Neustart erforderlich)" "max_retries": "Maximale Verbindungsversuche (Neustart erforderlich)",
"use_encryption": "Verschlüsselte Verbindung (erforderlich für neue Firmware-Versionen)"
} }
} }
}, },
@@ -17,7 +17,8 @@
"persistent_conn": "Persistent connection (restart required)", "persistent_conn": "Persistent connection (restart required)",
"polling_interval": "Polling interval in seconds (restart required)", "polling_interval": "Polling interval in seconds (restart required)",
"polling_timeout": "Polling timeout in seconds (restart required)", "polling_timeout": "Polling timeout in seconds (restart required)",
"max_retries": "Maximum amount of connection retries (restart required)" "max_retries": "Maximum amount of connection retries (restart required)",
"use_encryption": "Encrypted connection (necessary for latest firmware versions)"
} }
} }
}, },
+3
View File
@@ -2,4 +2,7 @@
# This script is only for development purposes # This script is only for development purposes
sudo rm -rf /var/ha_config/custom_components/*/__pycache__
sudo cp -r ./custom_components/* /var/ha_config/custom_components/ sudo cp -r ./custom_components/* /var/ha_config/custom_components/
docker restart homeassistant
docker logs -f homeassistant
+8 -1
View File
@@ -2,4 +2,11 @@ homeassistant
bleak_retry_connector bleak_retry_connector
pyserial pyserial
bluetooth_auto_recovery bluetooth_auto_recovery
crcmod crcmod
typing
asyncio
logging
bleak
async_timeout
aiousbwatcher
pyasn1
+2 -2
View File
@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
# Execute unittests of python library # Execute unittests of python library inside docker
python3 -m unittest discover -s tests -p "*_test.py" sudo docker build --progress=plain -t bluetti-bt-tests .
+9
View File
@@ -8,6 +8,7 @@ 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.ac70p import AC70P
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac180 import AC180 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.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.ac200m import AC200M
from custom_components.bluetti_bt.bluetti_bt_lib.devices.ac300 import AC300 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.ac500 import AC500
@@ -66,6 +67,14 @@ class TestDeviceBuilder(unittest.TestCase):
self.assertIsInstance(built, AC180P) self.assertIsInstance(built, AC180P)
self.assertEqual(built.address, bt_addr) 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): def test_build_ac200m(self):
bt_addr = "aa:bb:cc:dd:ee:ff" bt_addr = "aa:bb:cc:dd:ee:ff"
bt_name = "AC200M56786746478" bt_name = "AC200M56786746478"
+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)