diff --git a/README.rst b/README.rst index 4315c17..2cca627 100644 --- a/README.rst +++ b/README.rst @@ -6,6 +6,16 @@ This tool provides an MQTT interface to Bluetti power stations. State will be published to the ``bluetti/state/[DEVICE NAME]/[PROPERTY]`` topic, and commands can be sent to the ``bluetti/command/[DEVICE NAME]/[PROPERTY]`` topic. +Notes On This Fork +------------ + +Most of this repository is unchanged. It has been expanded to include support for AC180 and AC2A power stations. + +To install this version of bluetti_mqtt +1) clone repository to RPi +2) From inside the repository directory run `pip install .` It's generally a good idea to do this in a virtual environment. + + Installation ------------ diff --git a/bluetti_mqtt/bluetooth/__init__.py b/bluetti_mqtt/bluetooth/__init__.py index 5d5bea8..9be6c86 100644 --- a/bluetti_mqtt/bluetooth/__init__.py +++ b/bluetti_mqtt/bluetooth/__init__.py @@ -3,13 +3,13 @@ from typing import Set from bleak import BleakScanner from bleak.backends.device import BLEDevice -from bluetti_mqtt.core import BluettiDevice, AC200M, AC300, AC500, AC60, EP500, EP500P, EP600, EB3A +from bluetti_mqtt.core import BluettiDevice, AC200M, AC300, AC500, AC180, AC60, EP500, EP500P, EP600, EB3A, AC2A from .client import BluetoothClient from .exc import BadConnectionError, ModbusError, ParseError from .manager import MultiDeviceManager -DEVICE_NAME_RE = re.compile(r'^(AC200M|AC300|AC500|AC60|EP500P|EP500|EP600|EB3A)(\d+)$') +DEVICE_NAME_RE = re.compile(r'^(AC200M|AC300|AC500|AC60|AC180|EP500P|EP500|EP600|EB3A|AC2A)(\d+)$') async def scan_devices(): @@ -33,6 +33,8 @@ def build_device(address: str, name: str): return AC500(address, match[2]) if match[1] == 'AC60': return AC60(address, match[2]) + if match[1] == 'AC180': + return AC180(address, match[2]) if match[1] == 'EP500': return EP500(address, match[2]) if match[1] == 'EP500P': @@ -41,6 +43,8 @@ def build_device(address: str, name: str): return EP600(address, match[2]) if match[1] == 'EB3A': return EB3A(address, match[2]) + if match[1] == 'AC2A': + return AC2A(address, match[2]) async def check_addresses(addresses: Set[str]): diff --git a/bluetti_mqtt/core/__init__.py b/bluetti_mqtt/core/__init__.py index 2d16d41..41fe7b1 100644 --- a/bluetti_mqtt/core/__init__.py +++ b/bluetti_mqtt/core/__init__.py @@ -7,6 +7,8 @@ from .devices.ep500p import EP500P from .devices.ep600 import EP600 from .devices.eb3a import EB3A +from .devices.ac180 import AC180 +from .devices.ac2a import AC2A from .commands import ( DeviceCommand, ReadHoldingRegisters, diff --git a/bluetti_mqtt/core/devices/ac180.py b/bluetti_mqtt/core/devices/ac180.py new file mode 100644 index 0000000..9f538e5 --- /dev/null +++ b/bluetti_mqtt/core/devices/ac180.py @@ -0,0 +1,66 @@ +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + +class AC180(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + #Core + # self.struct.add_swap_string_field('device_type', 110, 6) + # self.struct.add_sn_field('serial_number', 116) + self.struct.add_swap_string_field('device_type', 1101, 6) + self.struct.add_sn_field('serial_number', 1107) + + #Battery Data + self.struct.add_swap_string_field('battery_type', 6101, 6) + self.struct.add_sn_field('battery_serial_number', 6107) + self.struct.add_version_field('bcu_version', 6175) + self.struct.add_uint_field('total_battery_percent', 102) + + #Power IO + self.struct.add_uint_field('output_mode',123) #32 when both loads off, 40 when AC is on, 48 when DC is on, 56 when both on + 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) #this is a guess because I didn't have a PV module handy to test + + #History + # self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) + self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) + + # this is usefule for investigating the available data + # registers = {0:21,100:67,700:6,720:49,1100:51,1200:90,1300:31,1400:48,1500:30,2000:67,2200:29,3000:27,6000:31,6100:100,6300:52,7000:5} + # for k in registers: + # for v in range(registers[k]): + # self.struct.add_uint_field('testI' + str(v+k), v+k) + + super().__init__(address, 'AC180', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0,21), + ReadHoldingRegisters(100, 67), + ReadHoldingRegisters(700,6), + ReadHoldingRegisters(720,49), + ReadHoldingRegisters(1100, 51), + ReadHoldingRegisters(1200, 90), + ReadHoldingRegisters(1300, 31), + ReadHoldingRegisters(1400, 48), + ReadHoldingRegisters(1500, 30), + ReadHoldingRegisters(2000, 67), + ReadHoldingRegisters(2200, 29), + ReadHoldingRegisters(3000, 27), + ReadHoldingRegisters(6000, 31), + ReadHoldingRegisters(6100, 100), + ReadHoldingRegisters(6300, 52), + ReadHoldingRegisters(7000,5) + ] diff --git a/bluetti_mqtt/core/devices/ac2a.py b/bluetti_mqtt/core/devices/ac2a.py new file mode 100644 index 0000000..c93a590 --- /dev/null +++ b/bluetti_mqtt/core/devices/ac2a.py @@ -0,0 +1,66 @@ +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + +class AC2A(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + #Core + # self.struct.add_swap_string_field('device_type', 110, 6) + # self.struct.add_sn_field('serial_number', 116) + self.struct.add_swap_string_field('device_type', 1101, 6) + self.struct.add_sn_field('serial_number', 1107) + + #Battery Data + self.struct.add_swap_string_field('battery_type', 6101, 6) + self.struct.add_sn_field('battery_serial_number', 6107) + self.struct.add_version_field('bcu_version', 6175) + self.struct.add_uint_field('total_battery_percent', 102) + + #Power IO + self.struct.add_uint_field('output_mode',123) #32 when both loads off, 40 when AC is on, 48 when DC is on, 56 when both on + 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) #this is a guess because I didn't have a PV module handy to test + + #History + # self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) + self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) + + # this is usefule for investigating the available data + # registers = {0:21,100:67,700:6,720:49,1100:51,1200:90,1300:31,1400:48,1500:30,2000:67,2200:29,3000:27,6000:31,6100:100,6300:52,7000:5} + # for k in registers: + # for v in range(registers[k]): + # self.struct.add_uint_field('testI' + str(v+k), v+k) + + super().__init__(address, 'AC2A', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0,21), + ReadHoldingRegisters(100, 62), + ReadHoldingRegisters(700,6), + ReadHoldingRegisters(720,49), + ReadHoldingRegisters(1100, 51), + ReadHoldingRegisters(1200, 90), + ReadHoldingRegisters(1300, 31), + ReadHoldingRegisters(1400, 48), + ReadHoldingRegisters(1500, 30), + ReadHoldingRegisters(2000, 67), + ReadHoldingRegisters(2200, 29), + ReadHoldingRegisters(3000, 27), + ReadHoldingRegisters(6000, 31), + ReadHoldingRegisters(6100, 100), + ReadHoldingRegisters(6300, 52), + ReadHoldingRegisters(7000,5) + ] diff --git a/build/lib/bluetti_mqtt/__init__.py b/build/lib/bluetti_mqtt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/bluetti_mqtt/bluetooth/__init__.py b/build/lib/bluetti_mqtt/bluetooth/__init__.py new file mode 100644 index 0000000..9be6c86 --- /dev/null +++ b/build/lib/bluetti_mqtt/bluetooth/__init__.py @@ -0,0 +1,59 @@ +import logging +import re +from typing import Set +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bluetti_mqtt.core import BluettiDevice, AC200M, AC300, AC500, AC180, AC60, EP500, EP500P, EP600, EB3A, AC2A +from .client import BluetoothClient +from .exc import BadConnectionError, ModbusError, ParseError +from .manager import MultiDeviceManager + + +DEVICE_NAME_RE = re.compile(r'^(AC200M|AC300|AC500|AC60|AC180|EP500P|EP500|EP600|EB3A|AC2A)(\d+)$') + + +async def scan_devices(): + print('Scanning....') + devices = await BleakScanner.discover() + if len(devices) == 0: + print('0 devices found - something probably went wrong') + else: + bluetti_devices = [d for d in devices if d.name and DEVICE_NAME_RE.match(d.name)] + for d in bluetti_devices: + print(f'Found {d.name}: address {d.address}') + + +def build_device(address: str, name: str): + match = DEVICE_NAME_RE.match(name) + 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] == 'AC60': + return AC60(address, match[2]) + if match[1] == 'AC180': + return AC180(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] == 'EB3A': + return EB3A(address, match[2]) + if match[1] == 'AC2A': + return AC2A(address, match[2]) + + +async def check_addresses(addresses: Set[str]): + logging.debug(f'Checking we can connect: {addresses}') + devices = await BleakScanner.discover() + filtered = [d for d in devices if d.address in addresses] + logging.debug(f'Found devices: {filtered}') + + if len(filtered) != len(addresses): + return [] + + return [build_device(d.address, d.name) for d in filtered] diff --git a/build/lib/bluetti_mqtt/bluetooth/client.py b/build/lib/bluetti_mqtt/bluetooth/client.py new file mode 100644 index 0000000..02d7acd --- /dev/null +++ b/build/lib/bluetti_mqtt/bluetooth/client.py @@ -0,0 +1,191 @@ +import asyncio +from enum import Enum, auto, unique +import logging +from typing import Union +from bleak import BleakClient, BleakError +from bleak.exc import BleakDeviceNotFoundError +from bluetti_mqtt.core import DeviceCommand +from .exc import BadConnectionError, ModbusError, ParseError + + +@unique +class ClientState(Enum): + NOT_CONNECTED = auto() + CONNECTED = auto() + READY = auto() + PERFORMING_COMMAND = auto() + COMMAND_ERROR_WAIT = auto() + DISCONNECTING = auto() + + +class BluetoothClient: + 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' + + name: Union[str, None] + current_command: DeviceCommand + notify_future: asyncio.Future + notify_response: bytearray + + def __init__(self, address: str): + self.address = address + self.state = ClientState.NOT_CONNECTED + self.name = None + self.client = BleakClient(self.address) + self.command_queue = asyncio.Queue() + self.notify_future = None + self.loop = asyncio.get_running_loop() + + @property + def is_ready(self): + return self.state == ClientState.READY or self.state == ClientState.PERFORMING_COMMAND + + async def perform(self, cmd: DeviceCommand): + future = self.loop.create_future() + await self.command_queue.put((cmd, future)) + return future + + async def perform_nowait(self, cmd: DeviceCommand): + await self.command_queue.put((cmd, None)) + + async def run(self): + try: + while True: + if self.state == ClientState.NOT_CONNECTED: + await self._connect() + elif self.state == ClientState.CONNECTED: + if not self.name: + await self._get_name() + else: + await self._start_listening() + elif self.state == ClientState.READY: + await self._perform_command() + elif self.state == ClientState.DISCONNECTING: + await self._disconnect() + else: + logging.warn(f'Unexpected current state {self.state}') + self.state = ClientState.NOT_CONNECTED + finally: + # Ensure that we disconnect + if self.client: + await self.client.disconnect() + + async def _connect(self): + """Establish connection to the bluetooth device""" + try: + await self.client.connect() + self.state = ClientState.CONNECTED + logging.info(f'Connected to device: {self.address}') + except BleakDeviceNotFoundError: + logging.debug(f'Error connecting to device {self.address}: Not found') + except (BleakError, EOFError, asyncio.TimeoutError): + logging.exception(f'Error connecting to device {self.address}:') + await asyncio.sleep(1) + + async def _get_name(self): + """Get device name, which can be parsed for type""" + try: + name = await self.client.read_gatt_char(self.DEVICE_NAME_UUID) + self.name = name.decode('ascii') + logging.info(f'Device {self.address} has name: {self.name}') + except BleakError: + logging.exception(f'Error retrieving device name {self.address}:') + self.state = ClientState.DISCONNECTING + + async def _start_listening(self): + """Register for command response notifications""" + try: + await self.client.start_notify( + self.NOTIFY_UUID, + self._notification_handler) + self.state = ClientState.READY + except BleakError: + self.state = ClientState.DISCONNECTING + + async def _perform_command(self): + cmd, cmd_future = await self.command_queue.get() + retries = 0 + while retries < 5: + try: + # Prepare to make request + self.state = ClientState.PERFORMING_COMMAND + self.current_command = cmd + self.notify_future = self.loop.create_future() + self.notify_response = bytearray() + + # Make request + await self.client.write_gatt_char( + self.WRITE_UUID, + bytes(self.current_command)) + + # Wait for response + res = await asyncio.wait_for( + self.notify_future, + timeout=self.RESPONSE_TIMEOUT) + if cmd_future: + cmd_future.set_result(res) + + # Success! + self.state = ClientState.READY + break + except ParseError: + # For safety, wait the full timeout before retrying again + self.state = ClientState.COMMAND_ERROR_WAIT + retries += 1 + await asyncio.sleep(self.RESPONSE_TIMEOUT) + except asyncio.TimeoutError: + self.state = ClientState.COMMAND_ERROR_WAIT + retries += 1 + except ModbusError as err: + if cmd_future: + cmd_future.set_exception(err) + + # Don't retry + self.state = ClientState.READY + break + except (BleakError, EOFError, BadConnectionError) as err: + if cmd_future: + cmd_future.set_exception(err) + + self.state = ClientState.DISCONNECTING + break + + if retries == 5: + err = BadConnectionError('too many retries') + if cmd_future: + cmd_future.set_exception(err) + self.state = ClientState.DISCONNECTING + + self.command_queue.task_done() + + async def _disconnect(self): + await self.client.disconnect() + logging.warn(f'Delayed reconnect to {self.address} after error') + await asyncio.sleep(5) + self.state = ClientState.NOT_CONNECTED + + def _notification_handler(self, _sender: int, data: bytearray): + # Ignore notifications we don't expect + if not self.notify_future or self.notify_future.done(): + return + + # If something went wrong, we might get weird data. + if data == b'AT+NAME?\r' or data == b'AT+ADV?\r': + err = BadConnectionError('Got AT+ notification') + self.notify_future.set_exception(err) + return + + # Save data + self.notify_response.extend(data) + + if len(self.notify_response) == self.current_command.response_size(): + if self.current_command.is_valid_response(self.notify_response): + self.notify_future.set_result(self.notify_response) + else: + self.notify_future.set_exception(ParseError('Failed checksum')) + elif self.current_command.is_exception_response(self.notify_response): + # We got a MODBUS command exception + msg = f'MODBUS Exception {self.current_command}: {self.notify_response[2]}' + self.notify_future.set_exception(ModbusError(msg)) diff --git a/build/lib/bluetti_mqtt/bluetooth/exc.py b/build/lib/bluetti_mqtt/bluetooth/exc.py new file mode 100644 index 0000000..fd42c94 --- /dev/null +++ b/build/lib/bluetti_mqtt/bluetooth/exc.py @@ -0,0 +1,12 @@ +class ParseError(Exception): + pass + + +class ModbusError(Exception): + """Used when the command returns a MODBUS exception""" + pass + + +# Triggers a re-connect +class BadConnectionError(Exception): + pass diff --git a/build/lib/bluetti_mqtt/bluetooth/manager.py b/build/lib/bluetti_mqtt/bluetooth/manager.py new file mode 100644 index 0000000..99b3a30 --- /dev/null +++ b/build/lib/bluetti_mqtt/bluetooth/manager.py @@ -0,0 +1,48 @@ +import asyncio +import logging +from typing import Dict, List +from bleak import BleakScanner +from bluetti_mqtt.core import DeviceCommand +from .client import BluetoothClient + + +class MultiDeviceManager: + clients: Dict[str, BluetoothClient] + + def __init__(self, addresses: List[str]): + self.addresses = addresses + self.clients = {} + + async def run(self): + logging.info(f'Connecting to clients: {self.addresses}') + + # Perform a blocking scan just to speed up initial connect + await BleakScanner.discover() + + # Start client loops + self.clients = {a: BluetoothClient(a) for a in self.addresses} + await asyncio.gather(*[c.run() for c in self.clients.values()]) + + def is_ready(self, address: str): + if address in self.clients: + return self.clients[address].is_ready + else: + return False + + def get_name(self, address: str): + if address in self.clients: + return self.clients[address].name + else: + raise Exception('Unknown address') + + async def perform(self, address: str, command: DeviceCommand): + if address in self.clients: + return await self.clients[address].perform(command) + else: + raise Exception('Unknown address') + + async def perform_nowait(self, address: str, command: DeviceCommand): + if address in self.clients: + await self.clients[address].perform_nowait(command) + else: + raise Exception('Unknown address') diff --git a/build/lib/bluetti_mqtt/bus.py b/build/lib/bluetti_mqtt/bus.py new file mode 100644 index 0000000..5ea17dd --- /dev/null +++ b/build/lib/bluetti_mqtt/bus.py @@ -0,0 +1,54 @@ +import asyncio +from dataclasses import dataclass +import logging +from typing import Callable, List, Union +from bluetti_mqtt.core import BluettiDevice, DeviceCommand + + +@dataclass(frozen=True) +class ParserMessage: + device: BluettiDevice + parsed: dict + + +@dataclass(frozen=True) +class CommandMessage: + device: BluettiDevice + command: DeviceCommand + + +class EventBus: + parser_listeners: List[Callable[[ParserMessage], None]] + command_listeners: List[Callable[[CommandMessage], None]] + queue: asyncio.Queue + + def __init__(self): + self.parser_listeners = [] + self.command_listeners = [] + self.queue = None + + def add_parser_listener(self, cb: Callable[[ParserMessage], None]): + self.parser_listeners.append(cb) + + def add_command_listener(self, cb: Callable[[CommandMessage], None]): + self.command_listeners.append(cb) + + async def put(self, msg: Union[ParserMessage, CommandMessage]): + if not self.queue: + self.queue = asyncio.Queue() + + await self.queue.put(msg) + + """Reads messages and notifies listeners""" + async def run(self): + if not self.queue: + self.queue = asyncio.Queue() + + while True: + msg = await self.queue.get() + logging.debug(f'queue size: {self.queue.qsize()}') + if isinstance(msg, ParserMessage): + await asyncio.gather(*[pl(msg) for pl in self.parser_listeners]) + elif isinstance(msg, CommandMessage): + await asyncio.gather(*[cl(msg) for cl in self.command_listeners]) + self.queue.task_done() diff --git a/build/lib/bluetti_mqtt/core/__init__.py b/build/lib/bluetti_mqtt/core/__init__.py new file mode 100644 index 0000000..41fe7b1 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/__init__.py @@ -0,0 +1,17 @@ +from .devices.bluetti_device import BluettiDevice +from .devices.ac200m import AC200M +from .devices.ac300 import AC300 +from .devices.ac500 import AC500 +from .devices.ac60 import AC60 +from .devices.ep500 import EP500 +from .devices.ep500p import EP500P +from .devices.ep600 import EP600 +from .devices.eb3a import EB3A +from .devices.ac180 import AC180 +from .devices.ac2a import AC2A +from .commands import ( + DeviceCommand, + ReadHoldingRegisters, + WriteSingleRegister, + WriteMultipleRegisters +) diff --git a/build/lib/bluetti_mqtt/core/commands.py b/build/lib/bluetti_mqtt/core/commands.py new file mode 100644 index 0000000..286de11 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/commands.py @@ -0,0 +1,105 @@ +import struct +from .utils import modbus_crc + + +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(' 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})' + ) diff --git a/build/lib/bluetti_mqtt/core/devices/__init__.py b/build/lib/bluetti_mqtt/core/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/bluetti_mqtt/core/devices/ac180.py b/build/lib/bluetti_mqtt/core/devices/ac180.py new file mode 100644 index 0000000..9f538e5 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ac180.py @@ -0,0 +1,66 @@ +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + +class AC180(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + #Core + # self.struct.add_swap_string_field('device_type', 110, 6) + # self.struct.add_sn_field('serial_number', 116) + self.struct.add_swap_string_field('device_type', 1101, 6) + self.struct.add_sn_field('serial_number', 1107) + + #Battery Data + self.struct.add_swap_string_field('battery_type', 6101, 6) + self.struct.add_sn_field('battery_serial_number', 6107) + self.struct.add_version_field('bcu_version', 6175) + self.struct.add_uint_field('total_battery_percent', 102) + + #Power IO + self.struct.add_uint_field('output_mode',123) #32 when both loads off, 40 when AC is on, 48 when DC is on, 56 when both on + 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) #this is a guess because I didn't have a PV module handy to test + + #History + # self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) + self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) + + # this is usefule for investigating the available data + # registers = {0:21,100:67,700:6,720:49,1100:51,1200:90,1300:31,1400:48,1500:30,2000:67,2200:29,3000:27,6000:31,6100:100,6300:52,7000:5} + # for k in registers: + # for v in range(registers[k]): + # self.struct.add_uint_field('testI' + str(v+k), v+k) + + super().__init__(address, 'AC180', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0,21), + ReadHoldingRegisters(100, 67), + ReadHoldingRegisters(700,6), + ReadHoldingRegisters(720,49), + ReadHoldingRegisters(1100, 51), + ReadHoldingRegisters(1200, 90), + ReadHoldingRegisters(1300, 31), + ReadHoldingRegisters(1400, 48), + ReadHoldingRegisters(1500, 30), + ReadHoldingRegisters(2000, 67), + ReadHoldingRegisters(2200, 29), + ReadHoldingRegisters(3000, 27), + ReadHoldingRegisters(6000, 31), + ReadHoldingRegisters(6100, 100), + ReadHoldingRegisters(6300, 52), + ReadHoldingRegisters(7000,5) + ] diff --git a/build/lib/bluetti_mqtt/core/devices/ac200m.py b/build/lib/bluetti_mqtt/core/devices/ac200m.py new file mode 100644 index 0000000..c2f99dd --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ac200m.py @@ -0,0 +1,101 @@ +from enum import Enum, unique +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +@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 + + +class AC200M(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + # Core + 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) + 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) + self.struct.add_decimal_field('power_generation', 41, 1) # Total power generated since last reset (kwh) + self.struct.add_uint_field('total_battery_percent', 43) + self.struct.add_bool_field('ac_output_on', 48) + self.struct.add_bool_field('dc_output_on', 49) + + # Details + self.struct.add_enum_field('ac_output_mode', 70, OutputMode) + self.struct.add_uint_field('internal_ac_voltage', 71) + 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, 1) + 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 Data + self.struct.add_uint_field('pack_num_max', 91) + self.struct.add_decimal_field('total_battery_voltage', 92, 2) + self.struct.add_uint_field('pack_num', 96) + 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) + + # Controls + self.struct.add_uint_field('pack_num', 3006) + self.struct.add_bool_field('ac_output_on', 3007) + self.struct.add_bool_field('dc_output_on', 3008) + # 3031-3033 is the current device time & date without a timezone + self.struct.add_bool_field('power_off', 3060) + self.struct.add_enum_field('auto_sleep_mode', 3061, AutoSleepMode) + + super().__init__(address, 'AC200M', sn) + + @property + def pack_num_max(self): + return 3 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 40), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 37)] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0, 70), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_logging_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 119)] + + @property + def writable_ranges(self) -> List[range]: + return [range(3000, 3062)] diff --git a/build/lib/bluetti_mqtt/core/devices/ac2a.py b/build/lib/bluetti_mqtt/core/devices/ac2a.py new file mode 100644 index 0000000..c93a590 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ac2a.py @@ -0,0 +1,66 @@ +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + +class AC2A(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + #Core + # self.struct.add_swap_string_field('device_type', 110, 6) + # self.struct.add_sn_field('serial_number', 116) + self.struct.add_swap_string_field('device_type', 1101, 6) + self.struct.add_sn_field('serial_number', 1107) + + #Battery Data + self.struct.add_swap_string_field('battery_type', 6101, 6) + self.struct.add_sn_field('battery_serial_number', 6107) + self.struct.add_version_field('bcu_version', 6175) + self.struct.add_uint_field('total_battery_percent', 102) + + #Power IO + self.struct.add_uint_field('output_mode',123) #32 when both loads off, 40 when AC is on, 48 when DC is on, 56 when both on + 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) #this is a guess because I didn't have a PV module handy to test + + #History + # self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) + self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) + + # this is usefule for investigating the available data + # registers = {0:21,100:67,700:6,720:49,1100:51,1200:90,1300:31,1400:48,1500:30,2000:67,2200:29,3000:27,6000:31,6100:100,6300:52,7000:5} + # for k in registers: + # for v in range(registers[k]): + # self.struct.add_uint_field('testI' + str(v+k), v+k) + + super().__init__(address, 'AC2A', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0,21), + ReadHoldingRegisters(100, 62), + ReadHoldingRegisters(700,6), + ReadHoldingRegisters(720,49), + ReadHoldingRegisters(1100, 51), + ReadHoldingRegisters(1200, 90), + ReadHoldingRegisters(1300, 31), + ReadHoldingRegisters(1400, 48), + ReadHoldingRegisters(1500, 30), + ReadHoldingRegisters(2000, 67), + ReadHoldingRegisters(2200, 29), + ReadHoldingRegisters(3000, 27), + ReadHoldingRegisters(6000, 31), + ReadHoldingRegisters(6100, 100), + ReadHoldingRegisters(6300, 52), + ReadHoldingRegisters(7000,5) + ] diff --git a/build/lib/bluetti_mqtt/core/devices/ac300.py b/build/lib/bluetti_mqtt/core/devices/ac300.py new file mode 100644 index 0000000..3a65b2d --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ac300.py @@ -0,0 +1,139 @@ +from enum import Enum, unique +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +@unique +class OutputMode(Enum): + STOP = 0 + INVERTER_OUTPUT = 1 + BYPASS_OUTPUT_C = 2 + BYPASS_OUTPUT_D = 3 + LOAD_MATCHING = 4 + + +@unique +class BatteryState(Enum): + STANDBY = 0 + CHARGE = 1 + DISCHARGE = 2 + + +@unique +class UpsMode(Enum): + CUSTOMIZED = 1 + PV_PRIORITY = 2 + STANDARD = 3 + TIME_CONTROL = 4 + + +@unique +class MachineAddress(Enum): + SLAVE = 0 + MASTER = 1 + + +@unique +class AutoSleepMode(Enum): + THIRTY_SECONDS = 2 + ONE_MINUTE = 3 + FIVE_MINUTES = 4 + NEVER = 5 + + +class AC300(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + # Core + 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) + 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) + self.struct.add_decimal_field('power_generation', 41, 1) # Total power generated since last reset (kwh) + self.struct.add_uint_field('total_battery_percent', 43) + self.struct.add_bool_field('ac_output_on', 48) + self.struct.add_bool_field('dc_output_on', 49) + + # 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_decimal_field('internal_dc_input_voltage', 86, 1) + self.struct.add_uint_field('internal_dc_input_power', 87) + self.struct.add_decimal_field('internal_dc_input_current', 88, 1, (0, 15)) + + # Battery Data + self.struct.add_uint_field('pack_num_max', 91) + self.struct.add_decimal_field('total_battery_voltage', 92, 1) + self.struct.add_decimal_field('total_battery_current', 93, 1) + self.struct.add_uint_field('pack_num', 96) + self.struct.add_enum_field('pack_status', 97, BatteryState) + 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) + 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, MachineAddress) + self.struct.add_uint_field('pack_num', 3006) + self.struct.add_bool_field('ac_output_on', 3007) + self.struct.add_bool_field('dc_output_on', 3008) + 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) + # 3031-3033 is the current device time & date without a timezone + self.struct.add_bool_field('bluetooth_connected', 3036) + # 3039-3056 is the time control programming + self.struct.add_enum_field('auto_sleep_mode', 3061, AutoSleepMode) + + super().__init__(address, 'AC300', sn) + + @property + def pack_num_max(self): + return 4 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 40), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 37)] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0, 70), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3000, 62), + ] + + @property + def pack_logging_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 119)] + + @property + def writable_ranges(self) -> List[range]: + return [range(3000, 3062)] diff --git a/build/lib/bluetti_mqtt/core/devices/ac500.py b/build/lib/bluetti_mqtt/core/devices/ac500.py new file mode 100644 index 0000000..3b46b30 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ac500.py @@ -0,0 +1,129 @@ +from enum import Enum, unique +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +@unique +class OutputMode(Enum): + STOP = 0 + INVERTER_OUTPUT = 1 + BYPASS_OUTPUT_C = 2 + BYPASS_OUTPUT_D = 3 + LOAD_MATCHING = 4 + + +@unique +class UpsMode(Enum): + CUSTOMIZED = 1 + PV_PRIORITY = 2 + STANDARD = 3 + TIME_CONTROL = 4 + + +@unique +class MachineAddress(Enum): + SLAVE = 0 + MASTER = 1 + + +@unique +class AutoSleepMode(Enum): + THIRTY_SECONDS = 2 + ONE_MINUTE = 3 + FIVE_MINUTES = 4 + NEVER = 5 + + +class AC500(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + # Core + 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) + 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) + self.struct.add_decimal_field('power_generation', 41, 1) # Total power generated since last reset (kwh) + self.struct.add_uint_field('total_battery_percent', 43) + self.struct.add_bool_field('ac_output_on', 48) + self.struct.add_bool_field('dc_output_on', 49) + + # 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) + self.struct.add_uint_field('internal_power_three', 79) + self.struct.add_decimal_field('ac_input_frequency', 80, 2) + self.struct.add_decimal_field('internal_dc_input_voltage', 86, 1) + self.struct.add_uint_field('internal_dc_input_power', 87) + self.struct.add_decimal_field('internal_dc_input_current', 88, 1) + + # Battery Data + self.struct.add_uint_field('pack_num_max', 91) + self.struct.add_decimal_field('total_battery_voltage', 92, 1) + self.struct.add_uint_field('pack_num', 96) + 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) + + # 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, MachineAddress) + self.struct.add_uint_field('pack_num', 3006) + self.struct.add_bool_field('ac_output_on', 3007) + self.struct.add_bool_field('dc_output_on', 3008) + 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) + # 3031-3033 is the current device time & date without a timezone + self.struct.add_bool_field('bluetooth_connected', 3036) + # 3039-3056 is the time control programming + self.struct.add_enum_field('auto_sleep_mode', 3061, AutoSleepMode) + + super().__init__(address, 'AC500', sn) + + @property + def pack_num_max(self): + return 6 + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 40), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 37)] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0, 70), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3000, 62), + ] + + @property + def pack_logging_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 119)] + + @property + def writable_ranges(self) -> List[range]: + return [range(3000, 3062)] diff --git a/build/lib/bluetti_mqtt/core/devices/ac60.py b/build/lib/bluetti_mqtt/core/devices/ac60.py new file mode 100644 index 0000000..69fce1b --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ac60.py @@ -0,0 +1,44 @@ +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +class AC60(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + self.struct.add_uint_field('total_battery_percent', 102) + self.struct.add_swap_string_field('device_type', 110, 6) + self.struct.add_sn_field('serial_number', 116) + self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) + self.struct.add_swap_string_field('device_type', 1101, 6) + self.struct.add_sn_field('serial_number', 1107) + self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) + self.struct.add_swap_string_field('battery_type', 6101, 6) + self.struct.add_sn_field('battery_serial_number', 6107) + self.struct.add_version_field('bcu_version', 6175) + + super().__init__(address, 'AC60', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ReadHoldingRegisters(1100, 51), + ReadHoldingRegisters(1200, 90), + ReadHoldingRegisters(1300, 31), + ReadHoldingRegisters(1400, 48), + ReadHoldingRegisters(1500, 30), + ReadHoldingRegisters(2000, 67), + ReadHoldingRegisters(2200, 29), + ReadHoldingRegisters(6000, 31), + ReadHoldingRegisters(6100, 100), + ReadHoldingRegisters(6300, 52), + ] diff --git a/build/lib/bluetti_mqtt/core/devices/bluetti_device.py b/build/lib/bluetti_mqtt/core/devices/bluetti_device.py new file mode 100644 index 0000000..c8be7a1 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/bluetti_device.py @@ -0,0 +1,68 @@ +from typing import Any, List +from ..commands import ReadHoldingRegisters, WriteSingleRegister +from .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 logging_commands(self) -> List[ReadHoldingRegisters]: + """A given device has an optimal set of commands for debug logging""" + raise NotImplementedError + + @property + def pack_logging_commands(self) -> List[ReadHoldingRegisters]: + """A given device may have a set of commands for logging pack data""" + return [] + + @property + def writable_ranges(self) -> List[range]: + """The address ranges that are writable""" + return [] + + def has_field(self, field: str): + return any(f.name == field for f in self.struct.fields) + + def has_field_setter(self, field: str): + matches = [f for f in self.struct.fields if f.name == field] + return any(any(f.address in r for r in self.writable_ranges) for f in matches) + + def build_setter_command(self, field: str, value: Any): + matches = [f for f in self.struct.fields if f.name == field] + device_field = next(f for f in matches if any(f.address in r for r in self.writable_ranges)) + + # Convert value to an integer + if isinstance(device_field, EnumField): + value = device_field.enum[value].value + elif isinstance(device_field, BoolField): + value = 1 if value else 0 + + return WriteSingleRegister(device_field.address, value) diff --git a/build/lib/bluetti_mqtt/core/devices/eb3a.py b/build/lib/bluetti_mqtt/core/devices/eb3a.py new file mode 100644 index 0000000..6d6804f --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/eb3a.py @@ -0,0 +1,87 @@ +from enum import Enum, unique +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +@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 + + +class EB3A(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + # Core + 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) + 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) + self.struct.add_uint_field('total_battery_percent', 43) + self.struct.add_bool_field('ac_output_on', 48) + self.struct.add_bool_field('dc_output_on', 49) + + # Details + self.struct.add_decimal_field('ac_input_voltage', 77, 1) + self.struct.add_decimal_field('internal_dc_input_voltage', 86, 2) + + # Battery Data + self.struct.add_uint_field('pack_num_max', 91) + + # Controls + self.struct.add_bool_field('ac_output_on', 3007) + self.struct.add_bool_field('dc_output_on', 3008) + 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) + + super().__init__(address, 'EB3A', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 40), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3034, 1), + ReadHoldingRegisters(3060, 7) + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 53), + ReadHoldingRegisters(70, 66), + ReadHoldingRegisters(136, 74), + ReadHoldingRegisters(3000, 67) + ] + + @property + def writable_ranges(self) -> List[range]: + return [range(3000, 3067)] diff --git a/build/lib/bluetti_mqtt/core/devices/ep500.py b/build/lib/bluetti_mqtt/core/devices/ep500.py new file mode 100644 index 0000000..838fe69 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ep500.py @@ -0,0 +1,125 @@ +from enum import Enum, unique +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +@unique +class OutputMode(Enum): + STOP = 0 + INVERTER_OUTPUT = 1 + BYPASS_OUTPUT_C = 2 + BYPASS_OUTPUT_D = 3 + LOAD_MATCHING = 4 + + +@unique +class UpsMode(Enum): + CUSTOMIZED = 1 + PV_PRIORITY = 2 + STANDARD = 3 + TIME_CONTROL = 4 + + +@unique +class MachineAddress(Enum): + SLAVE = 0 + MASTER = 1 + + +@unique +class AutoSleepMode(Enum): + THIRTY_SECONDS = 2 + ONE_MINUTE = 3 + FIVE_MINUTES = 4 + NEVER = 5 + + +class EP500(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + # Core + 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) + 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) + self.struct.add_decimal_field('power_generation', 41, 1) # Total power generated since last reset (kwh) + self.struct.add_uint_field('total_battery_percent', 43) + self.struct.add_bool_field('ac_output_on', 48) + self.struct.add_bool_field('dc_output_on', 49) + + # 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) + self.struct.add_uint_field('internal_power_three', 79) + self.struct.add_decimal_field('ac_input_frequency', 80, 2) + self.struct.add_decimal_field('internal_dc_input_voltage', 86, 1) + self.struct.add_uint_field('internal_dc_input_power', 87) + self.struct.add_decimal_field('internal_dc_input_current', 88, 1, (0, 15)) + + # Battery Data + self.struct.add_uint_field('pack_num_max', 91) + self.struct.add_decimal_field('total_battery_voltage', 92, 1) + self.struct.add_decimal_field('pack_voltage', 92, 1) # Full pack voltage + self.struct.add_uint_field('pack_battery_percent', 94) + self.struct.add_uint_field('pack_num', 96) + self.struct.add_decimal_array_field('cell_voltages', 105, 16, 2) + + # 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, MachineAddress) + self.struct.add_uint_field('pack_num', 3006) + self.struct.add_bool_field('ac_output_on', 3007) + self.struct.add_bool_field('dc_output_on', 3008) + 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) + # 3031-3033 is the current device time & date without a timezone + self.struct.add_bool_field('bluetooth_connected', 3036) + # 3039-3056 is the time control programming + self.struct.add_enum_field('auto_sleep_mode', 3061, AutoSleepMode) + + super().__init__(address, 'EP500', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 40), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 37)] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0, 70), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_logging_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 119)] + + @property + def writable_ranges(self) -> List[range]: + return [range(3000, 3062)] diff --git a/build/lib/bluetti_mqtt/core/devices/ep500p.py b/build/lib/bluetti_mqtt/core/devices/ep500p.py new file mode 100644 index 0000000..05cba56 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ep500p.py @@ -0,0 +1,125 @@ +from enum import Enum, unique +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +@unique +class OutputMode(Enum): + STOP = 0 + INVERTER_OUTPUT = 1 + BYPASS_OUTPUT_C = 2 + BYPASS_OUTPUT_D = 3 + LOAD_MATCHING = 4 + + +@unique +class UpsMode(Enum): + CUSTOMIZED = 1 + PV_PRIORITY = 2 + STANDARD = 3 + TIME_CONTROL = 4 + + +@unique +class MachineAddress(Enum): + SLAVE = 0 + MASTER = 1 + + +@unique +class AutoSleepMode(Enum): + THIRTY_SECONDS = 2 + ONE_MINUTE = 3 + FIVE_MINUTES = 4 + NEVER = 5 + + +class EP500P(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + # Core + 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) + 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) + self.struct.add_decimal_field('power_generation', 41, 1) # Total power generated since last reset (kwh) + self.struct.add_uint_field('total_battery_percent', 43) + self.struct.add_bool_field('ac_output_on', 48) + self.struct.add_bool_field('dc_output_on', 49) + + # 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) + self.struct.add_uint_field('internal_power_three', 79) + self.struct.add_decimal_field('ac_input_frequency', 80, 2) + self.struct.add_decimal_field('internal_dc_input_voltage', 86, 1) + self.struct.add_uint_field('internal_dc_input_power', 87) + self.struct.add_decimal_field('internal_dc_input_current', 88, 1, (0, 15)) + + # Battery Data + self.struct.add_uint_field('pack_num_max', 91) + self.struct.add_decimal_field('total_battery_voltage', 92, 1) + self.struct.add_decimal_field('pack_voltage', 92, 1) # Full pack voltage + self.struct.add_uint_field('pack_battery_percent', 94) + self.struct.add_uint_field('pack_num', 96) + self.struct.add_decimal_array_field('cell_voltages', 105, 16, 2) + + # 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, MachineAddress) + self.struct.add_uint_field('pack_num', 3006) + self.struct.add_bool_field('ac_output_on', 3007) + self.struct.add_bool_field('dc_output_on', 3008) + 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) + # 3031-3033 is the current device time & date without a timezone + self.struct.add_bool_field('bluetooth_connected', 3036) + # 3039-3056 is the time control programming + self.struct.add_enum_field('auto_sleep_mode', 3061, AutoSleepMode) + + super().__init__(address, 'EP500P', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(10, 40), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 37)] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(0, 70), + ReadHoldingRegisters(70, 21), + ReadHoldingRegisters(3001, 61), + ] + + @property + def pack_logging_commands(self) -> List[ReadHoldingRegisters]: + return [ReadHoldingRegisters(91, 119)] + + @property + def writable_ranges(self) -> List[range]: + return [range(3000, 3062)] diff --git a/build/lib/bluetti_mqtt/core/devices/ep600.py b/build/lib/bluetti_mqtt/core/devices/ep600.py new file mode 100644 index 0000000..9f14b9a --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/ep600.py @@ -0,0 +1,56 @@ +from typing import List +from ..commands import ReadHoldingRegisters +from .bluetti_device import BluettiDevice +from .struct import DeviceStruct + + +class EP600(BluettiDevice): + def __init__(self, address: str, sn: str): + self.struct = DeviceStruct() + + self.struct.add_uint_field('total_battery_percent', 102) + self.struct.add_swap_string_field('device_type', 110, 6) + self.struct.add_sn_field('serial_number', 116) + self.struct.add_decimal_field('power_generation', 154, 1) # Total power generated since last reset (kwh) + self.struct.add_swap_string_field('device_type', 1101, 6) + self.struct.add_sn_field('serial_number', 1107) + self.struct.add_decimal_field('power_generation', 1202, 1) # Total power generated since last reset (kwh) + # 2001-2003 is the current device time & date without a timezone + 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) + self.struct.add_swap_string_field('battery_type', 6101, 6) + self.struct.add_sn_field('battery_serial_number', 6107) + self.struct.add_version_field('bcu_version', 6175) + self.struct.add_version_field('bmu_version', 6178) + self.struct.add_version_field('safety_module_version', 6181) + self.struct.add_version_field('high_voltage_module_version', 6184) + + super().__init__(address, 'EP600', sn) + + @property + def polling_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ReadHoldingRegisters(2022, 2), + ] + + @property + def logging_commands(self) -> List[ReadHoldingRegisters]: + return [ + ReadHoldingRegisters(100, 62), + ReadHoldingRegisters(1100, 51), + ReadHoldingRegisters(1200, 90), + ReadHoldingRegisters(1300, 31), + ReadHoldingRegisters(1400, 48), + ReadHoldingRegisters(1500, 30), + ReadHoldingRegisters(2000, 89), + ReadHoldingRegisters(2200, 41), + ReadHoldingRegisters(2300, 36), + ReadHoldingRegisters(6000, 32), + ReadHoldingRegisters(6100, 100), + ReadHoldingRegisters(6300, 100), + ] diff --git a/build/lib/bluetti_mqtt/core/devices/struct.py b/build/lib/bluetti_mqtt/core/devices/struct.py new file mode 100644 index 0000000..198a127 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/devices/struct.py @@ -0,0 +1,175 @@ +from decimal import Decimal +from enum import Enum +import struct +from typing import Any, List, Optional, Tuple, Type + + +def swap_bytes(data: bytes): + """Swaps the place of every other byte, returning a new byte array""" + arr = bytearray(data) + for i in range(0, len(arr) - 1, 2): + arr[i], arr[i + 1] = arr[i + 1], arr[i] + return arr + + +class DeviceField: + def __init__(self, name: str, address: int, size: int): + self.name = name + self.address = address + self.size = size + + def parse(self, data: bytes) -> Any: + raise NotImplementedError + + def in_range(self, val: Any) -> bool: + return True + + +class UintField(DeviceField): + def __init__(self, name: str, address: int, range: Optional[Tuple[int, int]]): + self.range = range + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> int: + return struct.unpack('!H', data)[0] + + def in_range(self, val: int) -> bool: + if self.range is None: + return True + else: + return val >= self.range[0] and val <= self.range[1] + + +class BoolField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> bool: + return struct.unpack('!H', data)[0] == 1 + + +class EnumField(DeviceField): + def __init__(self, name: str, address: int, enum: Type[Enum]): + self.enum = enum + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Any: + val = struct.unpack('!H', data)[0] + return self.enum(val) + + +class DecimalField(DeviceField): + def __init__(self, name: str, address: int, scale: int, range: Optional[Tuple[int, int]]): + self.scale = scale + self.range = range + super().__init__(name, address, 1) + + def parse(self, data: bytes) -> Decimal: + val = Decimal(struct.unpack('!H', data)[0]) + return val / 10 ** self.scale + + def in_range(self, val: Decimal) -> bool: + if self.range is None: + return True + else: + return val >= self.range[0] and val <= self.range[1] + + +class DecimalArrayField(DeviceField): + def __init__(self, name: str, address: int, size: int, scale: int): + self.scale = scale + super().__init__(name, address, size) + + def parse(self, data: bytes) -> Decimal: + values = list(struct.unpack(f'!{self.size}H', data)) + return [Decimal(v) / 10 ** self.scale for v in values] + + +class StringField(DeviceField): + """Fixed-width null-terminated string field""" + def parse(self, data: bytes) -> str: + return data.rstrip(b'\0').decode('ascii') + + +class SwapStringField(DeviceField): + """Fixed-width null-terminated string field""" + def parse(self, data: bytes) -> str: + return swap_bytes(data).rstrip(b'\0').decode('ascii') + + +class VersionField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 2) + + def parse(self, data: bytes) -> int: + values = struct.unpack('!2H', data) + return Decimal(values[0] + (values[1] << 16)) / 100 + + +class SerialNumberField(DeviceField): + def __init__(self, name: str, address: int): + super().__init__(name, address, 4) + + def parse(self, data: bytes) -> int: + values = struct.unpack('!4H', data) + return values[0] + (values[1] << 16) + (values[2] << 32) + (values[3] << 48) + + +class DeviceStruct: + fields: List[DeviceField] + + def __init__(self): + self.fields = [] + + def add_uint_field(self, name: str, address: int, range: Tuple[int, int] = None): + self.fields.append(UintField(name, address, range)) + + def add_bool_field(self, name: str, address: int): + self.fields.append(BoolField(name, address)) + + def add_enum_field(self, name: str, address: int, enum: Type[Enum]): + self.fields.append(EnumField(name, address, enum)) + + def add_decimal_field(self, name: str, address: int, scale: int, range: Tuple[int, int] = None): + self.fields.append(DecimalField(name, address, scale, range)) + + def add_decimal_array_field(self, name: str, address: int, size: int, scale: int): + self.fields.append(DecimalArrayField(name, address, size, scale)) + + def add_string_field(self, name: str, address: int, size: int): + self.fields.append(StringField(name, address, size)) + + def add_swap_string_field(self, name: str, address: int, size: int): + self.fields.append(SwapStringField(name, address, size)) + + def add_version_field(self, name: str, address: int): + self.fields.append(VersionField(name, address)) + + def add_sn_field(self, name: str, address: int): + self.fields.append(SerialNumberField(name, address)) + + def parse(self, starting_address: int, data: bytes) -> dict: + # Offsets and size are counted in 2 byte chunks, so for the range we + # need to divide the byte size by 2 + data_size = int(len(data) / 2) + + # Filter out fields not in range + r = range(starting_address, starting_address + data_size) + fields = [f for f in self.fields + if f.address in r and f.address + f.size - 1 in r] + + # Parse fields + parsed = {} + for f in fields: + data_start = 2 * (f.address - starting_address) + field_data = data[data_start:data_start + 2 * f.size] + val = f.parse(field_data) + + # Skip if the value is "out-of-range" - sometimes the sensors + # report weird values + if not f.in_range(val): + continue + + parsed[f.name] = val + + return parsed diff --git a/build/lib/bluetti_mqtt/core/utils.py b/build/lib/bluetti_mqtt/core/utils.py new file mode 100644 index 0000000..36740b4 --- /dev/null +++ b/build/lib/bluetti_mqtt/core/utils.py @@ -0,0 +1,3 @@ +import crcmod.predefined + +modbus_crc = crcmod.predefined.mkCrcFun('modbus') diff --git a/build/lib/bluetti_mqtt/device_handler.py b/build/lib/bluetti_mqtt/device_handler.py new file mode 100644 index 0000000..db52ebb --- /dev/null +++ b/build/lib/bluetti_mqtt/device_handler.py @@ -0,0 +1,104 @@ +import asyncio +from bleak import BleakError +import logging +import time +from typing import Dict, List, cast +from bluetti_mqtt.bluetooth import BadConnectionError, MultiDeviceManager, ModbusError, ParseError, build_device +from bluetti_mqtt.bus import CommandMessage, EventBus, ParserMessage +from bluetti_mqtt.core import BluettiDevice, ReadHoldingRegisters + + +class DeviceHandler: + def __init__(self, addresses: List[str], interval: int, bus: EventBus): + self.manager = MultiDeviceManager(addresses) + self.devices: Dict[str, BluettiDevice] = {} + self.interval = interval + self.bus = bus + + async def run(self): + loop = asyncio.get_running_loop() + + # Start manager + manager_task = loop.create_task(self.manager.run()) + + # Connect to event bus + self.bus.add_command_listener(self.handle_command) + + # Poll the clients + logging.info('Starting to poll clients...') + polling_tasks = [self._poll(a) for a in self.manager.addresses] + pack_polling_tasks = [self._pack_poll(a) for a in self.manager.addresses] + await asyncio.gather(*(polling_tasks + pack_polling_tasks + [manager_task])) + + async def handle_command(self, msg: CommandMessage): + if self.manager.is_ready(msg.device.address): + logging.debug(f'Performing command {msg.device}: {msg.command}') + await self.manager.perform_nowait(msg.device.address, msg.command) + + async def _poll(self, address: str): + while True: + if not self.manager.is_ready(address): + logging.debug(f'Waiting for connection to {address} to start polling...') + await asyncio.sleep(1) + continue + + device = self._get_device(address) + + # Send all polling commands + start_time = time.monotonic() + for command in device.polling_commands: + await self._poll_with_command(device, command) + elapsed = time.monotonic() - start_time + + # Limit polling rate if interval provided + if self.interval > 0 and self.interval > elapsed: + await asyncio.sleep(self.interval - elapsed) + + async def _pack_poll(self, address: str): + while True: + if not self.manager.is_ready(address): + logging.debug(f'Waiting for connection to {address} to start pack polling...') + await asyncio.sleep(1) + continue + + # Break if there's nothing to poll + device = self._get_device(address) + if len(device.pack_logging_commands) == 0: + break + + start_time = time.monotonic() + for pack in range(1, device.pack_num_max + 1): + # Send pack set command if the device supports more than 1 pack + if device.pack_num_max > 1: + command = device.build_setter_command('pack_num', pack) + await self.manager.perform_nowait(address, command) + await asyncio.sleep(10) # We need to wait after switching packs for the data to be available + + # Poll + for command in device.pack_logging_commands: + await self._poll_with_command(device, command) + elapsed = time.monotonic() - start_time + + # Limit polling rate if interval provided + if self.interval > 0 and self.interval > elapsed: + await asyncio.sleep(self.interval - elapsed) + + async def _poll_with_command(self, device: BluettiDevice, command: ReadHoldingRegisters): + response_future = await self.manager.perform(device.address, command) + try: + response = cast(bytes, await response_future) + body = command.parse_response(response) + parsed = device.parse(command.starting_address, body) + await self.bus.put(ParserMessage(device, parsed)) + except ParseError: + logging.debug('Got a parse exception...') + except ModbusError as err: + logging.debug(f'Got an invalid request error for {command}: {err}') + except (BadConnectionError, BleakError) as err: + logging.debug(f'Needed to disconnect due to error: {err}') + + def _get_device(self, address: str): + if address not in self.devices: + name = self.manager.get_name(address) + self.devices[address] = build_device(address, name) + return self.devices[address] diff --git a/build/lib/bluetti_mqtt/discovery_cli.py b/build/lib/bluetti_mqtt/discovery_cli.py new file mode 100644 index 0000000..19a7479 --- /dev/null +++ b/build/lib/bluetti_mqtt/discovery_cli.py @@ -0,0 +1,128 @@ +import argparse +import asyncio +import base64 +from bleak import BleakError, BleakScanner +from io import TextIOWrapper +import json +import sys +import textwrap +import time +from typing import cast +from bluetti_mqtt.bluetooth import BluetoothClient, ModbusError, ParseError, BadConnectionError +from bluetti_mqtt.core import ReadHoldingRegisters + + +def log_packet(output: TextIOWrapper, data: bytes, command: ReadHoldingRegisters): + log_entry = { + 'type': 'client', + 'time': time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime()), + 'data': base64.b64encode(data).decode('ascii'), + 'command': base64.b64encode(bytes(command)).decode('ascii'), + } + output.write(json.dumps(log_entry) + '\n') + + +def log_invalid(output: TextIOWrapper, err: Exception, command: ReadHoldingRegisters): + log_entry = { + 'type': 'client', + 'time': time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime()), + 'error': err.args[0], + 'command': base64.b64encode(bytes(command)).decode('ascii'), + } + output.write(json.dumps(log_entry) + '\n') + + +async def log_command(client: BluetoothClient, command: ReadHoldingRegisters, log_file: TextIOWrapper): + response_future = await client.perform(command) + try: + response = cast(bytes, await response_future) + print(f'Device data readable at {command.starting_address}') + log_packet(log_file, response, command) + except (BadConnectionError, BleakError, ParseError) as err: + print(f'Got an error running command {command}: {err}') + log_invalid(log_file, err, command) + except ModbusError as err: + # This is expected if we attempt to access an invalid address + log_invalid(log_file, err, command) + + +async def scan_devices(): + print('Scanning....') + devices = await BleakScanner.discover() + if len(devices) == 0: + print('0 devices found - something probably went wrong') + else: + for d in devices: + print(f'Found {d.name}: address {d.address}') + + +async def discover(address: str, path: str): + print(f'Connecting to {address}') + client = BluetoothClient(address) + asyncio.get_running_loop().create_task(client.run()) + + with open(path, 'a') as log_file: + # Wait for device connection + while not client.is_ready: + print('Waiting for connection...') + await asyncio.sleep(1) + continue + + # Work our way through all the valid addresses + print('Discovering device data - THIS MAY TAKE SEVERAL HOURS') + print('0% complete with discovery') + max_address = 12500 + last_percent = 0 + for address in range(0, max_address + 1): + # Log progress + percent = int(address / max_address * 100) + if percent != last_percent: + print(f'{percent}% complete with discovery') + last_percent = percent + + # Query address + command = ReadHoldingRegisters(address, 1) + await log_command(client, command, log_file) + + +def main(): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='Attempts to discover supported MODBUS ranges for undocumented Bluetti devices', + epilog=textwrap.dedent("""\ + To use, run the scanner first: + %(prog)s --scan + + Once you have found your device you can run the discovery tool: + %(prog)s --log log-file.log 00:11:22:33:44:55 + + Before starting this process, it is advised to connect AC and DC + inputs (if supported) as well as to attach DC and AC loads. This + will help with interpreting the data. Once the discovery process is + complete, which may take a while, the data can be used to add + support. + """)) + parser.add_argument( + '--scan', + action='store_true', + help='Scans for devices and prints out addresses') + parser.add_argument( + '--log', + metavar='PATH', + help='Connect and write discovered data for the device to the file') + parser.add_argument( + 'address', + metavar='ADDRESS', + nargs='?', + help='The device MAC to connect to for discovery') + args = parser.parse_args() + if args.scan: + asyncio.run(scan_devices()) + elif args.log: + asyncio.run(discover(args.address, args.log)) + else: + parser.print_help() + + +if __name__ == "__main__": + main(sys.argv) diff --git a/build/lib/bluetti_mqtt/logger_cli.py b/build/lib/bluetti_mqtt/logger_cli.py new file mode 100644 index 0000000..f55d4b3 --- /dev/null +++ b/build/lib/bluetti_mqtt/logger_cli.py @@ -0,0 +1,125 @@ +import argparse +import asyncio +import base64 +from bleak import BleakError +from io import TextIOWrapper +import json +import sys +import textwrap +import time +from typing import cast +from bluetti_mqtt.bluetooth import ( + check_addresses, scan_devices, BluetoothClient, ModbusError, + ParseError, BadConnectionError +) +from bluetti_mqtt.core import ( + BluettiDevice, ReadHoldingRegisters, DeviceCommand +) + + +def log_packet(output: TextIOWrapper, data: bytes, command: DeviceCommand): + log_entry = { + 'type': 'client', + 'time': time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime()), + 'data': base64.b64encode(data).decode('ascii'), + 'command': base64.b64encode(bytes(command)).decode('ascii'), + } + output.write(json.dumps(log_entry) + '\n') + + +def log_invalid(output: TextIOWrapper, err: Exception, command: DeviceCommand): + log_entry = { + 'type': 'client', + 'time': time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime()), + 'error': err.args[0], + 'command': base64.b64encode(bytes(command)).decode('ascii'), + } + output.write(json.dumps(log_entry) + '\n') + + +async def log_command(client: BluetoothClient, device: BluettiDevice, command: DeviceCommand, log_file: TextIOWrapper): + response_future = await client.perform(command) + try: + response = cast(bytes, await response_future) + if isinstance(command, ReadHoldingRegisters): + body = command.parse_response(response) + parsed = device.parse(command.starting_address, body) + print(parsed) + log_packet(log_file, response, command) + except (BadConnectionError, BleakError, ModbusError, ParseError) as err: + print(f'Got an error running command {command}: {err}') + log_invalid(log_file, err, command) + + +async def log(address: str, path: str): + devices = await check_addresses({address}) + if len(devices) == 0: + sys.exit('Could not find the given device to connect to') + device = devices[0] + + print(f'Connecting to {device.address}') + client = BluetoothClient(device.address) + asyncio.get_running_loop().create_task(client.run()) + + with open(path, 'a') as log_file: + # Wait for device connection + while not client.is_ready: + print('Waiting for connection...') + await asyncio.sleep(1) + continue + + # Poll device + while True: + for command in device.logging_commands: + await log_command(client, device, command, log_file) + + # Skip pack polling if not available + if len(device.pack_logging_commands) == 0: + continue + + for pack in range(1, device.pack_num_max + 1): + # Send pack set command if the device supports more than 1 pack + if device.pack_num_max > 1: + command = device.build_setter_command('pack_num', pack) + await log_command(client, device, command, log_file) + await asyncio.sleep(10) # We need to wait after switching packs for the data to be available + + for command in device.pack_logging_commands: + await log_command(client, device, command, log_file) + + +def main(): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='Scans for Bluetti devices and logs information', + epilog=textwrap.dedent("""\ + To use, run the scanner first: + %(prog)s --scan + + Once you have found your device you can run the logger: + %(prog)s --log log-file.log 00:11:22:33:44:55 + """)) + parser.add_argument( + '--scan', + action='store_true', + help='Scans for devices and prints out addresses') + parser.add_argument( + '--log', + metavar='PATH', + help='Connect and log data for the device to the given file') + parser.add_argument( + 'address', + metavar='ADDRESS', + nargs='?', + help='The device MAC to connect to for logging') + args = parser.parse_args() + if args.scan: + asyncio.run(scan_devices()) + elif args.log: + asyncio.run(log(args.address, args.log)) + else: + parser.print_help() + + +if __name__ == "__main__": + main(sys.argv) diff --git a/build/lib/bluetti_mqtt/mqtt_client.py b/build/lib/bluetti_mqtt/mqtt_client.py new file mode 100644 index 0000000..dd62c4c --- /dev/null +++ b/build/lib/bluetti_mqtt/mqtt_client.py @@ -0,0 +1,729 @@ +import asyncio +from dataclasses import dataclass +from enum import auto, Enum, unique +import json +import logging +import re +from typing import List, Optional +from asyncio_mqtt import Client, MqttError +from paho.mqtt.client import MQTTMessage +from bluetti_mqtt.bus import CommandMessage, EventBus, ParserMessage +from bluetti_mqtt.core import BluettiDevice, DeviceCommand + + +@unique +class MqttFieldType(Enum): + NUMERIC = auto() + BOOL = auto() + ENUM = auto() + BUTTON = auto() + + +@dataclass(frozen=True) +class MqttFieldConfig: + type: MqttFieldType + setter: bool + advanced: bool # Do not export by default to Home Assistant + home_assistant_extra: dict + id_override: Optional[str] = None # Used to override Home Assistant field id + + +COMMAND_TOPIC_RE = re.compile(r'^bluetti/command/(\w+)-(\d+)/([a-z_]+)$') +NORMAL_DEVICE_FIELDS = { + 'dc_input_power': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'DC Input Power', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'ac_input_power': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'AC Input Power', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'ac_output_power': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'AC Output Power', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'dc_output_power': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'DC Output Power', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'power_generation': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'Total Power Generation', + 'unit_of_measurement': 'kWh', + 'device_class': 'energy', + 'state_class': 'total_increasing', + } + ), + 'total_battery_percent': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'Total Battery Percent', + 'unit_of_measurement': '%', + 'device_class': 'battery', + 'state_class': 'measurement', + } + ), + 'ac_output_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'AC Output', + 'device_class': 'outlet', + } + ), + 'dc_output_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'DC Output', + 'device_class': 'outlet', + } + ), + 'ac_output_mode': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'AC Output Mode', + } + ), + 'internal_ac_voltage': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal AC Voltage', + 'unit_of_measurement': 'V', + 'device_class': 'voltage', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_current_one': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal Current Sensor 1', + 'unit_of_measurement': 'A', + 'device_class': 'current', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_power_one': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal Power Sensor 1', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_ac_frequency': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal AC Frequency', + 'unit_of_measurement': 'Hz', + 'device_class': 'frequency', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_current_two': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal Current Sensor 2', + 'unit_of_measurement': 'A', + 'device_class': 'current', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_power_two': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal Power Sensor 2', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'ac_input_voltage': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'AC Input Voltage', + 'unit_of_measurement': 'V', + 'device_class': 'voltage', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_current_three': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal Current Sensor 3', + 'unit_of_measurement': 'A', + 'device_class': 'current', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'internal_power_three': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Internal Power Sensor 3', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'ac_input_frequency': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'AC Input Frequency', + 'unit_of_measurement': 'Hz', + 'device_class': 'frequency', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'total_battery_voltage': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Total Battery Voltage', + 'unit_of_measurement': 'V', + 'device_class': 'voltage', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'total_battery_current': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': 'Total Battery Current', + 'unit_of_measurement': 'A', + 'device_class': 'current', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'ups_mode': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'UPS Working Mode', + 'options': ['CUSTOMIZED', 'PV_PRIORITY', 'STANDARD', 'TIME_CONTROL'], + } + ), + 'split_phase_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=False, # For safety purposes, I'm not exposing this as a setter + advanced=False, + home_assistant_extra={ + 'name': 'Split Phase', + } + ), + 'split_phase_machine_mode': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=False, # For safety purposes, I'm not exposing this as a setter + advanced=False, + home_assistant_extra={ + 'name': 'Split Phase Machine', + } + ), + 'grid_charge_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Grid Charge', + } + ), + 'time_control_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Time Control', + } + ), + 'battery_range_start': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Battery Range Start', + 'step': 1, + 'min': 0, + 'max': 100, + 'unit_of_measurement': '%', + } + ), + 'battery_range_end': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Battery Range End', + 'step': 1, + 'min': 0, + 'max': 100, + 'unit_of_measurement': '%', + } + ), + 'led_mode': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'LED Mode', + 'icon': 'mdi:lightbulb', + 'options': ['LOW', 'HIGH', 'SOS', 'OFF'], + } + ), + 'power_off': MqttFieldConfig( + type=MqttFieldType.BUTTON, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Power Off', + 'payload_press': 'ON', + } + ), + 'auto_sleep_mode': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Screen Auto Sleep Mode', + 'icon': 'mdi:sleep', + 'options': ['THIRTY_SECONDS', 'ONE_MINUTE', 'FIVE_MINUTES', 'NEVER'], + } + ), + 'eco_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'ECO', + 'icon': 'mdi:sprout', + } + ), + 'eco_shutdown': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'ECO Shutdown', + 'icon': 'mdi:sprout', + 'options': ['ONE_HOUR', 'TWO_HOURS', 'THREE_HOURS', 'FOUR_HOURS'], + } + ), + 'charging_mode': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Charging Mode', + 'icon': 'mdi:battery-charging', + 'options': ['STANDARD', 'SILENT', 'TURBO'], + } + ), + 'power_lifting_on': MqttFieldConfig( + type=MqttFieldType.BOOL, + setter=True, + advanced=False, + home_assistant_extra={ + 'name': 'Power Lifting', + 'icon': 'mdi:arm-flex', + } + ), +} +DC_INPUT_FIELDS = { + 'dc_input_voltage1': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'DC Input Voltage 1', + 'unit_of_measurement': 'V', + 'device_class': 'voltage', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'dc_input_power1': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'DC Input Power 1', + 'unit_of_measurement': 'W', + 'device_class': 'power', + 'state_class': 'measurement', + 'force_update': True, + } + ), + 'dc_input_current1': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': 'DC Input Current 1', + 'unit_of_measurement': 'A', + 'device_class': 'current', + 'state_class': 'measurement', + 'force_update': True, + } + ), +} + + +def battery_pack_fields(pack: int): + return { + 'pack_status': MqttFieldConfig( + type=MqttFieldType.ENUM, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': f'Battery Pack {pack} Status', + 'value_template': '{{ value_json.status }}' + }, + id_override=f'pack_status{pack}' + ), + 'pack_voltage': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=True, + home_assistant_extra={ + 'name': f'Battery Pack {pack} Voltage', + 'unit_of_measurement': 'V', + 'device_class': 'voltage', + 'state_class': 'measurement', + 'force_update': True, + 'value_template': '{{ value_json.voltage }}' + }, + id_override=f'pack_voltage{pack}' + ), + 'pack_battery_percent': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + 'name': f'Battery Pack {pack} Percent', + 'unit_of_measurement': '%', + 'device_class': 'battery', + 'state_class': 'measurement', + 'value_template': '{{ value_json.percent }}' + }, + id_override=f'pack_percent{pack}' + ), + } + + +class MQTTClient: + devices: List[BluettiDevice] + message_queue: asyncio.Queue + + def __init__( + self, + bus: EventBus, + hostname: str, + home_assistant_mode: str, + port: int = 1883, + username: Optional[str] = None, + password: Optional[str] = None, + ): + self.bus = bus + self.hostname = hostname + self.port = port + self.username = username + self.password = password + self.home_assistant_mode = home_assistant_mode + self.devices = [] + + async def run(self): + while True: + logging.info('Connecting to MQTT broker...') + try: + async with Client( + hostname=self.hostname, + port=self.port, + username=self.username, + password=self.password + ) as client: + logging.info('Connected to MQTT broker') + + # Connect to event bus + self.message_queue = asyncio.Queue() + self.bus.add_parser_listener(self.handle_message) + + # Handle pub/sub + await asyncio.gather( + self._handle_commands(client), + self._handle_messages(client) + ) + except MqttError: + logging.exception('MQTT error:') + await asyncio.sleep(5) + + async def handle_message(self, msg: ParserMessage): + await self.message_queue.put(msg) + + async def _handle_commands(self, client: Client): + async with client.filtered_messages('bluetti/command/#') as messages: + await client.subscribe('bluetti/command/#') + async for mqtt_message in messages: + await self._handle_command(mqtt_message) + + async def _handle_messages(self, client: Client): + while True: + msg: ParserMessage = await self.message_queue.get() + if msg.device not in self.devices: + await self._init_device(msg.device, client) + await self._handle_message(client, msg) + self.message_queue.task_done() + + async def _init_device(self, device: BluettiDevice, client: Client): + # Register device + self.devices.append(device) + + # Skip announcing device to Home Assistant if disabled + if self.home_assistant_mode == 'none': + return + + def payload(id: str, device: BluettiDevice, field: MqttFieldConfig) -> str: + ha_id = id if not field.id_override else field.id_override + payload_dict = { + 'state_topic': f'bluetti/state/{device.type}-{device.sn}/{id}', + 'device': { + 'identifiers': [ + f'{device.sn}' + ], + 'manufacturer': 'Bluetti', + 'name': f'{device.type} {device.sn}', + 'model': device.type + }, + 'unique_id': f'{device.sn}_{ha_id}', + 'object_id': f'{device.type}_{ha_id}', + } + if field.setter: + payload_dict['command_topic'] = f'bluetti/command/{device.type}-{device.sn}/{id}' + payload_dict.update(field.home_assistant_extra) + + return json.dumps(payload_dict, separators=(',', ':')) + + # Publish normal fields + for name, field in NORMAL_DEVICE_FIELDS.items(): + # Skip fields not supported by the device + if not device.has_field(name): + continue + + # Skip advanced fields if not enabled + if field.advanced and self.home_assistant_mode != 'advanced': + continue + + # Figure out Home Assistant type + if field.type == MqttFieldType.NUMERIC: + type = 'number' if field.setter else 'sensor' + elif field.type == MqttFieldType.BOOL: + type = 'switch' if field.setter else 'binary_sensor' + elif field.type == MqttFieldType.ENUM: + type = 'select' if field.setter else 'sensor' + elif field.type == MqttFieldType.BUTTON: + type = 'button' + + # Publish config + await client.publish( + f'homeassistant/{type}/{device.sn}_{name}/config', + payload=payload(name, device, field).encode(), + retain=True + ) + + # Publish battery pack configs + for pack in range(1, device.pack_num_max + 1): + fields = battery_pack_fields(pack) + for name, field in fields.items(): + # Skip fields not supported by the device + if not device.has_field(name): + continue + + # Publish config + await client.publish( + f'homeassistant/sensor/{device.sn}_{field.id_override}/config', + payload=payload(f'pack_details{pack}', device, field).encode(), + retain=True + ) + + # Publish DC input config + if device.has_field('internal_dc_input_voltage'): + for name, field in DC_INPUT_FIELDS.items(): + await client.publish( + f'homeassistant/sensor/{device.sn}_{name}/config', + payload=payload(name, device, field).encode(), + retain=True + ) + + logging.info(f'Sent discovery message of {device.type}-{device.sn} to Home Assistant') + + async def _handle_command(self, mqtt_message: MQTTMessage): + # Parse the mqtt_message.topic + m = COMMAND_TOPIC_RE.match(mqtt_message.topic) + if not m: + logging.warn(f'unknown command topic: {mqtt_message.topic}') + return + + # Find the matching device for the command + device = next((d for d in self.devices if d.type == m[1] and d.sn == m[2]), None) + if not device: + logging.warn(f'unknown device: {m[1]} {m[2]}') + return + + # Check if the device supports setting this field + if not device.has_field_setter(m[3]): + logging.warn(f'Received command for unknown topic: {m[3]} - {mqtt_message.topic}') + return + + cmd: DeviceCommand = None + if m[3] in NORMAL_DEVICE_FIELDS: + field = NORMAL_DEVICE_FIELDS[m[3]] + if field.type == MqttFieldType.ENUM: + value = mqtt_message.payload.decode('ascii') + cmd = device.build_setter_command(m[3], value) + elif field.type == MqttFieldType.BOOL or field.type == MqttFieldType.BUTTON: + value = mqtt_message.payload == b'ON' + cmd = device.build_setter_command(m[3], value) + elif field.type == MqttFieldType.NUMERIC: + value = int(mqtt_message.payload.decode('ascii')) + cmd = device.build_setter_command(m[3], value) + else: + raise AssertionError(f'unexpected enum type: {field.type}') + else: + logging.warn(f'Received command for unhandled topic: {m[3]} - {mqtt_message.topic}') + return + + await self.bus.put(CommandMessage(device, cmd)) + + async def _handle_message(self, client: Client, msg: ParserMessage): + logging.debug(f'Got a message from {msg.device}: {msg.parsed}') + topic_prefix = f'bluetti/state/{msg.device.type}-{msg.device.sn}/' + + # Publish normal fields + for name, value in msg.parsed.items(): + # Skip unconfigured fields + if name not in NORMAL_DEVICE_FIELDS: + continue + + # Build payload string + field = NORMAL_DEVICE_FIELDS[name] + if field.type == MqttFieldType.NUMERIC: + payload = str(value) + elif field.type == MqttFieldType.BOOL or field.type == MqttFieldType.BUTTON: + payload = 'ON' if value else 'OFF' + elif field.type == MqttFieldType.ENUM: + payload = value.name + else: + assert False, f'Unhandled field type: {field.type.name}' + + await client.publish(topic_prefix + name, payload=payload.encode()) + + # Publish battery pack data + pack_details = self._build_pack_details(msg.parsed) + if 'pack_num' in msg.parsed and len(pack_details) > 0: + await client.publish( + topic_prefix + f'pack_details{msg.parsed["pack_num"]}', + payload=json.dumps(pack_details, separators=(',', ':')).encode() + ) + + # Publish DC input data + if 'internal_dc_input_voltage' in msg.parsed: + await client.publish( + topic_prefix + 'dc_input_voltage1', + payload=str(msg.parsed['internal_dc_input_voltage']).encode() + ) + if 'internal_dc_input_power' in msg.parsed: + await client.publish( + topic_prefix + 'dc_input_power1', + payload=str(msg.parsed['internal_dc_input_power']).encode() + ) + if 'internal_dc_input_current' in msg.parsed: + await client.publish( + topic_prefix + 'dc_input_current1', + payload=str(msg.parsed['internal_dc_input_current']).encode() + ) + + def _build_pack_details(self, parsed: dict): + details = {} + if 'pack_status' in parsed: + details['status'] = parsed['pack_status'].name + if 'pack_battery_percent' in parsed: + details['percent'] = parsed['pack_battery_percent'] + if 'pack_voltage' in parsed: + details['voltage'] = float(parsed['pack_voltage']) + if 'cell_voltages' in parsed: + details['voltages'] = [float(d) for d in parsed['cell_voltages']] + return details diff --git a/build/lib/bluetti_mqtt/server_cli.py b/build/lib/bluetti_mqtt/server_cli.py new file mode 100644 index 0000000..82b36d1 --- /dev/null +++ b/build/lib/bluetti_mqtt/server_cli.py @@ -0,0 +1,160 @@ +import argparse +import asyncio +import logging +import os +import signal +from typing import List +import warnings +import sys +from bluetti_mqtt.bluetooth import scan_devices +from bluetti_mqtt.bus import EventBus +from bluetti_mqtt.device_handler import DeviceHandler +from bluetti_mqtt.mqtt_client import MQTTClient + + +class CommandLineHandler: + def __init__(self, argv=None): + self.argv = argv or sys.argv[:] + + def execute(self): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='Scans for Bluetti devices and logs information') + parser.add_argument( + '--scan', + action='store_true', + help='Scans for devices and prints out addresses') + parser.add_argument( + '--broker', + metavar='HOST', + dest='hostname', + help='The MQTT broker host to connect to') + parser.add_argument( + '--port', + default=1883, + type=int, + help='The MQTT broker port to connect to - defaults to %(default)s') + parser.add_argument( + '--username', + type=str, + help='The optional MQTT broker username') + parser.add_argument( + '--password', + type=str, + help='The optional MQTT broker password') + parser.add_argument( + '--interval', + default=0, + type=int, + help='The polling interval - default is to poll as fast as possible') + parser.add_argument( + '--ha-config', + default='normal', + choices=['normal', 'none', 'advanced'], + help='What fields to configure in Home Assistant - defaults to most fields ("normal")') + parser.add_argument( + 'addresses', + metavar='ADDRESS', + nargs='*', + help='The device MAC(s) to connect to') + + # The default event loop on windows doesn't support add_reader, which + # is required by asyncio-mqtt + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + args = parser.parse_args() + if args.scan: + asyncio.run(scan_devices()) + elif args.hostname and len(args.addresses) > 0: + self.start(args) + else: + parser.print_help() + + def start(self, args: argparse.Namespace): + loop = asyncio.get_event_loop() + + # Register signal handlers for safe shutdown + if sys.platform != 'win32': + signals = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT) + for s in signals: + loop.add_signal_handler(s, lambda: asyncio.create_task(shutdown(loop))) + + # Register a global exception handler so we don't hang + loop.set_exception_handler(handle_global_exception) + + try: + loop.create_task(self.run(args)) + loop.run_forever() + finally: + loop.close() + logging.debug("Shut down completed") + + async def run(self, args: argparse.Namespace): + loop = asyncio.get_running_loop() + bus = EventBus() + + # Set up strong reference for tasks + self.background_tasks = set() + + # Start event bus + bus_task = loop.create_task(bus.run()) + self.background_tasks.add(bus_task) + bus_task.add_done_callback(self.background_tasks.discard) + + # Start MQTT client + mqtt_client = MQTTClient( + bus=bus, + hostname=args.hostname, + home_assistant_mode=args.ha_config, + port=args.port, + username=args.username, + password=args.password, + ) + mqtt_task = loop.create_task(mqtt_client.run()) + self.background_tasks.add(mqtt_task) + mqtt_task.add_done_callback(self.background_tasks.discard) + + # Start bluetooth handler (manages connections) + addresses: List[str] = list(set(args.addresses)) + handler = DeviceHandler(addresses, args.interval, bus) + bluetooth_task = loop.create_task(handler.run()) + self.background_tasks.add(bluetooth_task) + bluetooth_task.add_done_callback(self.background_tasks.discard) + + +def handle_global_exception(loop, context): + if 'exception' in context: + logging.error('Crashing with uncaught exception:', exc_info=context['exception']) + else: + logging.error(f'Crashing with uncaught exception: {context["message"]}') + asyncio.create_task(shutdown(loop)) + + +async def shutdown(loop: asyncio.AbstractEventLoop): + logging.info('Shutting down...') + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + [task.cancel() for task in tasks] + await asyncio.gather(*tasks, return_exceptions=True) + loop.stop() + + +def main(argv=None): + debug = os.environ.get('DEBUG') + level = logging.INFO + if debug: + level = logging.DEBUG + warnings.simplefilter('always') + + logging.basicConfig( + datefmt='%Y-%m-%d %H:%M:%S', + format='%(asctime)s %(levelname)-8s %(message)s', + level=level + ) + + cli = CommandLineHandler(argv) + cli.execute() + + +if __name__ == "__main__": + main(sys.argv)