diff --git a/bluetti_mqtt/bluetooth/__init__.py b/bluetti_mqtt/bluetooth/__init__.py index 5d5bea8..cd5e3ce 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, AC60, EP500, EP500P, EP600, EB3A, AC200L 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|EP500P|EP500|EP600|EB3A|AC200L)(\d+)$') async def scan_devices(): @@ -27,6 +27,8 @@ 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] == 'AC200L': + return AC200L(address, match[2]) if match[1] == 'AC300': return AC300(address, match[2]) if match[1] == 'AC500': diff --git a/bluetti_mqtt/core/__init__.py b/bluetti_mqtt/core/__init__.py index 2d16d41..a4e3e60 100644 --- a/bluetti_mqtt/core/__init__.py +++ b/bluetti_mqtt/core/__init__.py @@ -7,6 +7,7 @@ from .devices.ep500p import EP500P from .devices.ep600 import EP600 from .devices.eb3a import EB3A +from .devices.ac200l import AC200L from .commands import ( DeviceCommand, ReadHoldingRegisters, diff --git a/bluetti_mqtt/core/devices/ac200l.py b/bluetti_mqtt/core/devices/ac200l.py new file mode 100644 index 0000000..3fc4f9a --- /dev/null +++ b/bluetti_mqtt/core/devices/ac200l.py @@ -0,0 +1,111 @@ +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 + + +@unique +class EcoShutdown(Enum): + ONE_HOUR = 1 + TWO_HOURS = 2 + THREE_HOURS = 3 + FOUR_HOURS = 4 + + +class AC200L(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) + self.struct.add_bool_field('eco_on', 3063) + self.struct.add_enum_field('eco_shutdown', 3064, EcoShutdown) + + super().__init__(address, 'AC200L', 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, 64), + ] + + @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, 3065)] diff --git a/bluetti_mqtt/mqtt_client.py b/bluetti_mqtt/mqtt_client.py index dd62c4c..03936f5 100644 --- a/bluetti_mqtt/mqtt_client.py +++ b/bluetti_mqtt/mqtt_client.py @@ -28,7 +28,7 @@ class MqttFieldConfig: id_override: Optional[str] = None # Used to override Home Assistant field id -COMMAND_TOPIC_RE = re.compile(r'^bluetti/command/(\w+)-(\d+)/([a-z_]+)$') +COMMAND_TOPIC_RE = re.compile(r'bluetti/command/(\w+)-(\d+)/([a-z_]+)$') NORMAL_DEVICE_FIELDS = { 'dc_input_power': MqttFieldConfig( type=MqttFieldType.NUMERIC, @@ -494,6 +494,7 @@ def __init__( bus: EventBus, hostname: str, home_assistant_mode: str, + topic: str, port: int = 1883, username: Optional[str] = None, password: Optional[str] = None, @@ -504,6 +505,7 @@ def __init__( self.username = username self.password = password self.home_assistant_mode = home_assistant_mode + self.topic = "custom-data" if topic == None else topic self.devices = [] async def run(self): @@ -535,8 +537,8 @@ 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 with client.filtered_messages(f'{self.topic}/bluetti/command/#') as messages: + await client.subscribe(f'{self.topic}/bluetti/command/#') async for mqtt_message in messages: await self._handle_command(mqtt_message) @@ -558,8 +560,10 @@ async def _init_device(self, device: BluettiDevice, client: Client): 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}', + 'state_topic': f'{self.topic}/bluetti/state/{device.type}-{device.sn}/{id}', 'device': { 'identifiers': [ f'{device.sn}' @@ -572,7 +576,7 @@ def payload(id: str, device: BluettiDevice, field: MqttFieldConfig) -> str: 'object_id': f'{device.type}_{ha_id}', } if field.setter: - payload_dict['command_topic'] = f'bluetti/command/{device.type}-{device.sn}/{id}' + payload_dict['command_topic'] = f'{self.topic}/bluetti/command/{device.type}-{device.sn}/{id}' payload_dict.update(field.home_assistant_extra) return json.dumps(payload_dict, separators=(',', ':')) @@ -632,7 +636,7 @@ def payload(id: str, device: BluettiDevice, field: MqttFieldConfig) -> str: async def _handle_command(self, mqtt_message: MQTTMessage): # Parse the mqtt_message.topic - m = COMMAND_TOPIC_RE.match(mqtt_message.topic) + m = COMMAND_TOPIC_RE.search(mqtt_message.topic) if not m: logging.warn(f'unknown command topic: {mqtt_message.topic}') return @@ -670,7 +674,7 @@ async def _handle_command(self, mqtt_message: MQTTMessage): 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}/' + topic_prefix = f'{self.topic}/bluetti/state/{msg.device.type}-{msg.device.sn}/' # Publish normal fields for name, value in msg.parsed.items(): diff --git a/bluetti_mqtt/server_cli.py b/bluetti_mqtt/server_cli.py index 82b36d1..a7f33e1 100644 --- a/bluetti_mqtt/server_cli.py +++ b/bluetti_mqtt/server_cli.py @@ -42,6 +42,10 @@ def execute(self): '--password', type=str, help='The optional MQTT broker password') + parser.add_argument( + '--topic', + type=str, + help='The MQTT broker topic - default: custom-data/bluetti/{state,command}/{device})') parser.add_argument( '--interval', default=0, @@ -107,6 +111,7 @@ async def run(self, args: argparse.Namespace): bus=bus, hostname=args.hostname, home_assistant_mode=args.ha_config, + topic=args.topic, port=args.port, username=args.username, password=args.password, diff --git a/requirements.txt b/requirements.txt index caac230..ca95cd0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ asyncio-mqtt==0.12.1 -bleak==0.14.3 +bleak==0.22.2 crcmod==1.7 dbus-next==0.2.3 paho-mqtt==1.6.1