From 0cadbb9931216590426f58b39bbc1be5b2584a4e Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Mon, 28 Aug 2023 15:03:43 -0400 Subject: [PATCH 01/18] Remove extra connection attempt, rm callback debug login when not connected --- custom_components/bluetti_bt/binary_sensor.py | 4 ++++ custom_components/bluetti_bt/coordinator.py | 16 ---------------- custom_components/bluetti_bt/sensor.py | 4 ++++ custom_components/bluetti_bt/switch.py | 4 ++++ 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/custom_components/bluetti_bt/binary_sensor.py b/custom_components/bluetti_bt/binary_sensor.py index b177e1c..9395da9 100644 --- a/custom_components/bluetti_bt/binary_sensor.py +++ b/custom_components/bluetti_bt/binary_sensor.py @@ -104,6 +104,10 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + + if not self.coordinator.client.is_connected: + return + _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) if not isinstance(self.coordinator.data, dict): _LOGGER.debug( diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index f8ded43..66caf77 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -179,22 +179,6 @@ class PollingCoordinator(DataUpdateCoordinator): parsed_data: dict = {} - # Connect to device - try: - async with async_timeout.timeout(20): - await self.client.connect() - - await self.client.start_notify( - BluetoothClient.NOTIFY_UUID, self._notification_handler - ) - self.has_notifier = True - except TimeoutError: - self.logger.debug("Connection timed out for device %s", mac_loggable(self._address)) - return None - except BleakError as err: - self.logger.debug("Bleak error: %s", err) - return None - try: async with async_timeout.timeout(15): diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 9721f70..286f391 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -130,6 +130,10 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + + if not self.coordinator.client.is_connected: + return + _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) if not isinstance(self.coordinator.data, dict): _LOGGER.debug( diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py index 6e4d5f0..1f44dc4 100644 --- a/custom_components/bluetti_bt/switch.py +++ b/custom_components/bluetti_bt/switch.py @@ -114,6 +114,10 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + + if not self.coordinator.client.is_connected: + return + _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) if not isinstance(self.coordinator.data, dict): _LOGGER.error( From 296c9cbfd357054845428f718cb87f7d5931cde0 Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Mon, 28 Aug 2023 15:33:45 -0400 Subject: [PATCH 02/18] Ensure this only applies to persistent connections --- custom_components/bluetti_bt/binary_sensor.py | 4 ++-- custom_components/bluetti_bt/sensor.py | 2 +- custom_components/bluetti_bt/switch.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/bluetti_bt/binary_sensor.py b/custom_components/bluetti_bt/binary_sensor.py index 9395da9..a52a269 100644 --- a/custom_components/bluetti_bt/binary_sensor.py +++ b/custom_components/bluetti_bt/binary_sensor.py @@ -105,8 +105,8 @@ class BluettiBinarySensor(CoordinatorEntity, BinarySensorEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if not self.coordinator.client.is_connected: - return + if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: + return _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) if not isinstance(self.coordinator.data, dict): diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 286f391..9dedc9b 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -131,7 +131,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if not self.coordinator.client.is_connected: + if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: return _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py index 1f44dc4..1ca2c69 100644 --- a/custom_components/bluetti_bt/switch.py +++ b/custom_components/bluetti_bt/switch.py @@ -115,7 +115,7 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if not self.coordinator.client.is_connected: + if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: return _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) From 39c75b0f75e4277c3cd2410720a6f02451b3ee78 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 30 Aug 2023 21:02:39 +0200 Subject: [PATCH 03/18] update version number --- custom_components/bluetti_bt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bluetti_bt/manifest.json b/custom_components/bluetti_bt/manifest.json index 1fe3fa9..da4e654 100644 --- a/custom_components/bluetti_bt/manifest.json +++ b/custom_components/bluetti_bt/manifest.json @@ -18,5 +18,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", "requirements": ["bluetti_mqtt==0.15.0"], - "version": "0.0.10" + "version": "0.0.11" } From b8a73e0ab906a26ac8a1cb1295b2b654a1cbf17b Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Wed, 30 Aug 2023 15:34:22 -0400 Subject: [PATCH 04/18] add polling for battery pack voltage & percent --- custom_components/bluetti_bt/coordinator.py | 100 +++++++++++++++++++- custom_components/bluetti_bt/sensor.py | 10 +- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 66caf77..c991d98 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -80,6 +80,10 @@ class DummyDevice(BluettiDevice): super().__init__(device.address, device.type, device.sn) self._parent = device + @property + def pack_num_max(self): + return self._parent.pack_num_max + @property def polling_commands(self) -> List[ReadHoldingRegisters]: if self.type == "EP600": @@ -112,7 +116,7 @@ class DummyDevice(BluettiDevice): @property def pack_polling_commands(self) -> List[ReadHoldingRegisters]: - return self._parent.pack_logging_commands + return self._parent.pack_polling_commands @property def logging_commands(self) -> List[ReadHoldingRegisters]: @@ -180,7 +184,7 @@ class PollingCoordinator(DataUpdateCoordinator): parsed_data: dict = {} try: - async with async_timeout.timeout(15): + async with async_timeout.timeout(45): # Reconnect if not connected if not self.client.is_connected: @@ -238,6 +242,98 @@ class PollingCoordinator(DataUpdateCoordinator): self.logger.warning( "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err ) + + # pack polling + for pack in range (1, self.bluetti_device.pack_num_max + 1): + # TODO need to set pack num ... + # We need to wait after switching packs for the data to be available (as per bluetti_mqtt device_handler.py) + try: + # Prepare to make request + command = self.bluetti_device.build_setter_command('pack_num', pack) + self.current_command = command + self.notify_future = self.hass.loop.create_future() + self.notify_response = bytearray() + + # Make request + self.logger.debug("Requesting %s : pack_num(%s)", command, pack) + await self.client.write_gatt_char( + BluetoothClient.WRITE_UUID, bytes(command) + ) + + # Wait for response + res = await asyncio.wait_for( + self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT + ) + # nothing to process - setting current pack_num + + except TimeoutError: + self.logger.debug( + "Polling timed out (address: %s)", mac_loggable(self._address) + ) + except ModbusError as err: + self.logger.warning( + "Got an invalid request error for %s: %s", + command, + err, + ) + except (BadConnectionError, BleakError) as err: + self.logger.warning( + "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err + ) + + for command in self.bluetti_device.pack_polling_commands: + try: + # Prepare to make request + self.current_command = command + self.notify_future = self.hass.loop.create_future() + self.notify_response = bytearray() + + # Make request + self.logger.debug("Requesting %s", command) + await self.client.write_gatt_char( + BluetoothClient.WRITE_UUID, bytes(command) + ) + + # Wait for response + res = await asyncio.wait_for( + self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT + ) + + # Process data + self.logger.debug("Got %s bytes", len(res)) + response = cast(bytes, res) + body = command.parse_response(response) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + + self.logger.debug("Parsed data: %s", parsed) + + packNo = parsed.get('pack_num') + if not isinstance(packNo, int) or packNo != pack: + self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", packNo, pack) + continue + + for key, value in parsed.items(): + parsed_data.update({key+str(pack):value}) + + except TimeoutError: + self.logger.debug( + "Polling timed out (address: %s)", mac_loggable(self._address) + ) + except ParseError: + self.logger.warning("Got a parse exception...") + except ModbusError as err: + self.logger.warning( + "Got an invalid request error for %s: %s", + command, + err, + ) + except (BadConnectionError, BleakError) as err: + self.logger.warning( + "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err + ) + except TimeoutError: self.logger.debug("Polling timed out for device %s", mac_loggable(self._address)) return None diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 9dedc9b..12579dc 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -27,6 +27,7 @@ from bluetti_mqtt.mqtt_client import ( NORMAL_DEVICE_FIELDS, DC_INPUT_FIELDS, MqttFieldType, + battery_pack_fields, ) from . import device_info as dev_info, get_unique_id @@ -59,8 +60,14 @@ async def async_setup_entry( all_fields = NORMAL_DEVICE_FIELDS all_fields.update(DC_INPUT_FIELDS) all_fields.update(ADDITIONAL_DEVICE_FIELDS) + # add pack fields for device + _LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max) + for pack in range (1, bluetti_device.pack_num_max + 1): + for name, field in battery_pack_fields(pack).items(): + all_fields.update({name+str(pack):field}) + for field_key, field_config in all_fields.items(): - if bluetti_device.has_field(field_key): + if bluetti_device.has_field(field_key) or field_config.id_override is not None: category = None if field_config.setter is True or field_key in DIAGNOSTIC_FIELDS: category = EntityCategory.DIAGNOSTIC @@ -144,6 +151,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): response_data = self.coordinator.data.get(self._response_key) if response_data is None: + _LOGGER.debug("No data for available for (%s)",self._response_key) self._attr_available = False return From 7c25cbbe27f359c0add99ec7982b8e595ea88a7e Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Wed, 30 Aug 2023 18:02:40 -0400 Subject: [PATCH 05/18] remove commentary --- custom_components/bluetti_bt/coordinator.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index c991d98..c909744 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -245,8 +245,6 @@ class PollingCoordinator(DataUpdateCoordinator): # pack polling for pack in range (1, self.bluetti_device.pack_num_max + 1): - # TODO need to set pack num ... - # We need to wait after switching packs for the data to be available (as per bluetti_mqtt device_handler.py) try: # Prepare to make request command = self.bluetti_device.build_setter_command('pack_num', pack) From 32cfc34c37aa16200c7be37d66722b0b318327c7 Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Thu, 31 Aug 2023 23:42:00 -0400 Subject: [PATCH 06/18] refactor send command & return response within new func --- custom_components/bluetti_bt/coordinator.py | 148 +++++++------------- 1 file changed, 47 insertions(+), 101 deletions(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index c909744..29379b7 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -184,7 +184,7 @@ class PollingCoordinator(DataUpdateCoordinator): parsed_data: dict = {} try: - async with async_timeout.timeout(45): + async with async_timeout.timeout(30): # Reconnect if not connected if not self.client.is_connected: @@ -199,112 +199,34 @@ class PollingCoordinator(DataUpdateCoordinator): for command in self.bluetti_device.polling_commands: try: - # Prepare to make request - self.current_command = command - self.notify_future = self.hass.loop.create_future() - self.notify_response = bytearray() - - # Make request - self.logger.debug("Requesting %s", command) - await self.client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) + body = command.parse_response( + await self.async_send_command(command) ) - - # Wait for response - res = await asyncio.wait_for( - self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT - ) - - # Process data - self.logger.debug("Got %s bytes", len(res)) - response = cast(bytes, res) - body = command.parse_response(response) parsed = self.bluetti_device.parse( command.starting_address, body ) - self.logger.debug("Parsed data: %s", parsed) parsed_data.update(parsed) - except TimeoutError: - self.logger.debug( - "Polling timed out (address: %s)", mac_loggable(self._address) - ) except ParseError: self.logger.warning("Got a parse exception...") - except ModbusError as err: - self.logger.warning( - "Got an invalid request error for %s: %s", - command, - err, - ) - except (BadConnectionError, BleakError) as err: - self.logger.warning( - "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err - ) # pack polling for pack in range (1, self.bluetti_device.pack_num_max + 1): - try: - # Prepare to make request - command = self.bluetti_device.build_setter_command('pack_num', pack) - self.current_command = command - self.notify_future = self.hass.loop.create_future() - self.notify_response = bytearray() - - # Make request - self.logger.debug("Requesting %s : pack_num(%s)", command, pack) - await self.client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) - ) - - # Wait for response - res = await asyncio.wait_for( - self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT - ) - # nothing to process - setting current pack_num - - except TimeoutError: - self.logger.debug( - "Polling timed out (address: %s)", mac_loggable(self._address) - ) - except ModbusError as err: - self.logger.warning( - "Got an invalid request error for %s: %s", - command, - err, - ) - except (BadConnectionError, BleakError) as err: - self.logger.warning( - "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err + # Set current pack number + await self.async_send_command( + self.bluetti_device.build_setter_command('pack_num', pack) ) for command in self.bluetti_device.pack_polling_commands: + # Request & parse result for each pack try: - # Prepare to make request - self.current_command = command - self.notify_future = self.hass.loop.create_future() - self.notify_response = bytearray() - - # Make request - self.logger.debug("Requesting %s", command) - await self.client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) + body = command.parse_response( + await self.async_send_command(command) ) - - # Wait for response - res = await asyncio.wait_for( - self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT - ) - - # Process data - self.logger.debug("Got %s bytes", len(res)) - response = cast(bytes, res) - body = command.parse_response(response) parsed = self.bluetti_device.parse( command.starting_address, body ) - self.logger.debug("Parsed data: %s", parsed) packNo = parsed.get('pack_num') @@ -315,22 +237,8 @@ class PollingCoordinator(DataUpdateCoordinator): for key, value in parsed.items(): parsed_data.update({key+str(pack):value}) - except TimeoutError: - self.logger.debug( - "Polling timed out (address: %s)", mac_loggable(self._address) - ) except ParseError: self.logger.warning("Got a parse exception...") - except ModbusError as err: - self.logger.warning( - "Got an invalid request error for %s: %s", - command, - err, - ) - except (BadConnectionError, BleakError) as err: - self.logger.warning( - "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err - ) except TimeoutError: self.logger.debug("Polling timed out for device %s", mac_loggable(self._address)) @@ -348,6 +256,44 @@ class PollingCoordinator(DataUpdateCoordinator): # Pass data back to sensors return parsed_data + async def async_send_command(self, command: ReadHoldingRegisters) -> bytes: + """Send command and return response""" + try: + # Prepare to make request + self.current_command = command + self.notify_future = self.hass.loop.create_future() + self.notify_response = bytearray() + + # Make request + self.logger.debug("Requesting %s (%s)", command) + await self.client.write_gatt_char( + BluetoothClient.WRITE_UUID, bytes(command) + ) + + # Wait for response + res = await asyncio.wait_for( + self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT + ) + + # Process data + self.logger.debug("Got %s bytes", len(res)) + return cast(bytes, res) + + except TimeoutError: + self.logger.debug( + "Polling timed out (address: %s)", mac_loggable(self._address) + ) + except ModbusError as err: + self.logger.warning( + "Got an invalid request error for %s: %s", + command, + err, + ) + except (BadConnectionError, BleakError) as err: + self.logger.warning( + "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err + ) + def _notification_handler(self, _sender: int, data: bytearray): """Handle bt data.""" From d38c30011c25231b8781cb09430a5a4bd822f942 Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:51:57 -0400 Subject: [PATCH 07/18] return empty bytes object vs None on exception --- custom_components/bluetti_bt/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 29379b7..d308907 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -293,6 +293,9 @@ class PollingCoordinator(DataUpdateCoordinator): self.logger.warning( "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err ) + + # caught an exception, return empty bytes object + return bytes() def _notification_handler(self, _sender: int, data: bytearray): """Handle bt data.""" From c3afdcfe890f08152adb0f29a84b085be03145f7 Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Sun, 3 Sep 2023 23:33:26 -0400 Subject: [PATCH 08/18] Allow switches with persistent_conn and add mutex to guard polling --- custom_components/bluetti_bt/__init__.py | 5 - custom_components/bluetti_bt/coordinator.py | 114 ++++++++++---------- custom_components/bluetti_bt/switch.py | 53 +++++---- 3 files changed, 84 insertions(+), 88 deletions(-) diff --git a/custom_components/bluetti_bt/__init__.py b/custom_components/bluetti_bt/__init__.py index 107be83..300ef90 100644 --- a/custom_components/bluetti_bt/__init__.py +++ b/custom_components/bluetti_bt/__init__.py @@ -28,11 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: polling_interval = entry.data.get(CONF_POLLING_INTERVAL, 20) persistent_conn = entry.data.get(CONF_PERSISTENT_CONN, False) - # Prevent persistant connection and controls at same time - if use_controls is True and persistent_conn is True: - _LOGGER.warning("Enabling controls and persistant connection at the same time is currently not supported. Disabled persistant connection") - persistent_conn = False - if address is None: return False diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index d308907..2169ce8 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -167,6 +167,9 @@ class PollingCoordinator(DataUpdateCoordinator): return None self.client = BleakClient(device) + # polling mutex to guard against switches + self.polling_lock = asyncio.Lock() + async def _async_update_data(self): """Fetch data from API endpoint. @@ -183,43 +186,22 @@ class PollingCoordinator(DataUpdateCoordinator): parsed_data: dict = {} - try: - async with async_timeout.timeout(30): + async with self.polling_lock: + try: + async with async_timeout.timeout(30): - # Reconnect if not connected - if not self.client.is_connected: - await self.client.connect() + # Reconnect if not connected + if not self.client.is_connected: + await self.client.connect() - # Attach notifier if needed - if not self.has_notifier: - await self.client.start_notify( - BluetoothClient.NOTIFY_UUID, self._notification_handler - ) - self.has_notifier = True - - for command in self.bluetti_device.polling_commands: - try: - body = command.parse_response( - await self.async_send_command(command) + # Attach notifier if needed + if not self.has_notifier: + await self.client.start_notify( + BluetoothClient.NOTIFY_UUID, self._notification_handler ) - parsed = self.bluetti_device.parse( - command.starting_address, body - ) - self.logger.debug("Parsed data: %s", parsed) - parsed_data.update(parsed) + self.has_notifier = True - except ParseError: - self.logger.warning("Got a parse exception...") - - # pack polling - for pack in range (1, self.bluetti_device.pack_num_max + 1): - # Set current pack number - await self.async_send_command( - self.bluetti_device.build_setter_command('pack_num', pack) - ) - - for command in self.bluetti_device.pack_polling_commands: - # Request & parse result for each pack + for command in self.bluetti_device.polling_commands: try: body = command.parse_response( await self.async_send_command(command) @@ -228,33 +210,55 @@ class PollingCoordinator(DataUpdateCoordinator): command.starting_address, body ) self.logger.debug("Parsed data: %s", parsed) - - packNo = parsed.get('pack_num') - if not isinstance(packNo, int) or packNo != pack: - self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", packNo, pack) - continue - - for key, value in parsed.items(): - parsed_data.update({key+str(pack):value}) + parsed_data.update(parsed) except ParseError: self.logger.warning("Got a parse exception...") - except TimeoutError: - self.logger.debug("Polling timed out for device %s", mac_loggable(self._address)) - return None - except BleakError as err: - self.logger.warning("Bleak error: %s", err) - return None - finally: - # Disconnect if connection not persistant - if not self.persistent_conn: - await self.client.disconnect() + # pack polling + for pack in range (1, self.bluetti_device.pack_num_max + 1): + # Set current pack number + await self.async_send_command( + self.bluetti_device.build_setter_command('pack_num', pack) + ) + + for command in self.bluetti_device.pack_polling_commands: + # Request & parse result for each pack + try: + body = command.parse_response( + await self.async_send_command(command) + ) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + self.logger.debug("Parsed data: %s", parsed) - self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False + packNo = parsed.get('pack_num') + if not isinstance(packNo, int) or packNo != pack: + self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", packNo, pack) + continue - # Pass data back to sensors - return parsed_data + for key, value in parsed.items(): + parsed_data.update({key+str(pack):value}) + + except ParseError: + self.logger.warning("Got a parse exception...") + + except TimeoutError: + self.logger.debug("Polling timed out for device %s", mac_loggable(self._address)) + return None + except BleakError as err: + self.logger.warning("Bleak error: %s", err) + return None + finally: + # Disconnect if connection not persistant + if not self.persistent_conn: + await self.client.disconnect() + + self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False + + # Pass data back to sensors + return parsed_data async def async_send_command(self, command: ReadHoldingRegisters) -> bytes: """Send command and return response""" @@ -265,7 +269,7 @@ class PollingCoordinator(DataUpdateCoordinator): self.notify_response = bytearray() # Make request - self.logger.debug("Requesting %s (%s)", command) + self.logger.debug("Requesting %s", command) await self.client.write_gatt_char( BluetoothClient.WRITE_UUID, bytes(command) ) diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py index 1ca2c69..184cb55 100644 --- a/custom_components/bluetti_bt/switch.py +++ b/custom_components/bluetti_bt/switch.py @@ -99,6 +99,9 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): super().__init__(coordinator) self._bluetti_device = bluetti_device + self._coordinator = coordinator + self._client = coordinator.client + self._polling_lock = coordinator.polling_lock e_name = f"{device_info.get('name')} {name}" self._address = address self._response_key = response_key @@ -156,38 +159,32 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): async def write_to_device(self, state: bool): """Write to device.""" - device = bluetooth.async_ble_device_from_address(self.hass, self._address) - if device is None: - _LOGGER.error("Device %s not available", mac_loggable(self._address)) - return - command = self._bluetti_device.build_setter_command(self._response_key, state) - # Wait until polling is done - while self.hass.data[DOMAIN][self._entry_id][DATA_POLLING_RUNNING] is True: - _LOGGER.debug("Waiting for polling to finish...") - asyncio.sleep(1) + async with self._polling_lock: + try: + async with async_timeout.timeout(15): + if not self._client.is_connected: + await self._client.connect() - client = BleakClient(device) - try: - async with async_timeout.timeout(15): - await client.connect() + # Send command + _LOGGER.debug("Requesting %s (%s,%s)", command, self._response_key, state) + await self._client.write_gatt_char( + BluetoothClient.WRITE_UUID, bytes(command) + ) - # Send command - await client.write_gatt_char( - BluetoothClient.WRITE_UUID, bytes(command) - ) + # Wait until device has changed value, otherwise reading register might reset it + await asyncio.sleep(5) - # Wait until device has changed value, otherwise reading register might reset it - await asyncio.sleep(5) - - except TimeoutError: - _LOGGER.error("Timed out for device %s", mac_loggable(self._address)) - return None - except BleakError as err: - _LOGGER.error("Bleak error: %s", err) - return None - finally: - await client.disconnect() + except TimeoutError: + _LOGGER.error("Timed out for device %s", mac_loggable(self._address)) + return None + except BleakError as err: + _LOGGER.error("Bleak error: %s", err) + return None + finally: + # Disconnect if connection not persistant + if not self._coordinator.persistent_conn: + await self._client.disconnect() await self.coordinator.async_request_refresh() From 3d8cf4b98f718fe5c3c5448ae05bd8a2a5dd1fe8 Mon Sep 17 00:00:00 2001 From: SupaFlav <52390958+SupaFlav@users.noreply.github.com> Date: Fri, 8 Sep 2023 19:56:12 -0400 Subject: [PATCH 09/18] Add 5x reconnect attempts on poll --- custom_components/bluetti_bt/coordinator.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 2169ce8..06bda77 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -191,8 +191,18 @@ class PollingCoordinator(DataUpdateCoordinator): async with async_timeout.timeout(30): # Reconnect if not connected - if not self.client.is_connected: - await self.client.connect() + max_retries = 5 + for attempt in range(1,max_retries + 1): + try: + if not self.client.is_connected: + await self.client.connect() + break + except Exception as e: + if attempt == max_retries: + raise # pass exception on max_retries attempt + else: + self.logger.debug(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...") + await asyncio.sleep(2) # Attach notifier if needed if not self.has_notifier: From 7904a07e7f4e438b49a5f4c62969b49bc3ba1a45 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sat, 23 Sep 2023 17:52:18 +0200 Subject: [PATCH 10/18] added ep600 dc input, prevent pack polling --- custom_components/bluetti_bt/config_flow.py | 2 +- custom_components/bluetti_bt/const.py | 72 +++++++++++++++++++ custom_components/bluetti_bt/coordinator.py | 80 ++++++++++----------- custom_components/bluetti_bt/sensor.py | 4 +- custom_components/bluetti_bt/switch.py | 4 +- 5 files changed, 115 insertions(+), 47 deletions(-) diff --git a/custom_components/bluetti_bt/config_flow.py b/custom_components/bluetti_bt/config_flow.py index d487271..975a068 100644 --- a/custom_components/bluetti_bt/config_flow.py +++ b/custom_components/bluetti_bt/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector from . import get_type_by_bt_name -from .const import CONF_PERSISTENT_CONN, CONF_POLLING_INTERVAL, DATA_COORDINATOR, DOMAIN, CONF_USE_CONTROLS +from .const import CONF_PERSISTENT_CONN, CONF_POLLING_INTERVAL, DOMAIN, CONF_USE_CONTROLS _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/bluetti_bt/const.py b/custom_components/bluetti_bt/const.py index acbf2ab..202cf06 100644 --- a/custom_components/bluetti_bt/const.py +++ b/custom_components/bluetti_bt/const.py @@ -79,6 +79,78 @@ ADDITIONAL_DEVICE_FIELDS = { advanced=False, home_assistant_extra={"name": "High Voltage Safety Module Version"}, ), + 'pv_input_power1': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + "name": "Solar Input Power 1", + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + "force_update": True, + }, + ), + 'pv_input_voltage1': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + "name": "Solar Input Voltage 1", + "unit_of_measurement": "V", + "device_class": "voltage", + "state_class": "measurement", + "force_update": True, + }, + ), + 'pv_input_current1': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + "name": "Solar Input Current 1", + "unit_of_measurement": "A", + "device_class": "current", + "state_class": "measurement", + "force_update": True, + }, + ), + 'pv_input_power2': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + "name": "Solar Input Power 2", + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + "force_update": True, + }, + ), + 'pv_input_voltage2': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + "name": "Solar Input Voltage 2", + "unit_of_measurement": "V", + "device_class": "voltage", + "state_class": "measurement", + "force_update": True, + }, + ), + 'pv_input_current2': MqttFieldConfig( + type=MqttFieldType.NUMERIC, + setter=False, + advanced=False, + home_assistant_extra={ + "name": "Solar Input Current 2", + "unit_of_measurement": "A", + "device_class": "current", + "state_class": "measurement", + "force_update": True, + }, + ), "adl400_ac_input_power_phase1": MqttFieldConfig( type=MqttFieldType.NUMERIC, setter=False, diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 06bda77..534d686 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -44,6 +44,13 @@ class DummyDevice(BluettiDevice): self.struct = device.struct if device.type == "EP600": + # DC Solar Input (copied from PR https://github.com/warhammerkid/bluetti_mqtt/pull/87 by KM011092) + self.struct.add_uint_field('pv_input_power1', 1212) # MPP 1 in - value * 0.1 + #self.struct.add_uint_field('pv_input_voltage1', 1213) # MPP 1 in - value * 0.1 + #self.struct.add_uint_field('pv_input_current1', 1214) # MPP 1 in + self.struct.add_uint_field('pv_input_power2', 1220) # MPP 2 in - value * 0.1 + #self.struct.add_uint_field('pv_input_voltage2', 1221) # MPP 2 in - value * 0.1 + #self.struct.add_uint_field('pv_input_current2', 1222) # MPP 2 in # ADL400 Smart Meter for AC Solar self.struct.add_uint_field("adl400_ac_input_power_phase1", 1228) self.struct.add_uint_field("adl400_ac_input_power_phase2", 1236) @@ -73,9 +80,6 @@ class DummyDevice(BluettiDevice): self.struct.add_decimal_field("ac_output_current_phase1", 1512, 1) self.struct.add_decimal_field("ac_output_current_phase2", 1519, 1) self.struct.add_decimal_field("ac_output_current_phase3", 1526, 1) - # Testing - # for i in range(0, 10): - # self.struct.add_decimal_field("testing"+str(i), 1520+i, 1) super().__init__(device.address, device.type, device.sn) self._parent = device @@ -94,28 +98,18 @@ class DummyDevice(BluettiDevice): ReadHoldingRegisters(1500, 27), ReadHoldingRegisters(2022, 6), ReadHoldingRegisters(2213, 4), + ReadHoldingRegisters(1212, 11), # Battery ReadHoldingRegisters(6101, 7), ReadHoldingRegisters(6175, 11), - # Testing - # ReadHoldingRegisters(100, 62), - # ReadHoldingRegisters(1100, 51), - # ReadHoldingRegisters(1200, 80), - # 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), ] return self._parent.polling_commands @property def pack_polling_commands(self) -> List[ReadHoldingRegisters]: + if self.type == "EP600": + return [] return self._parent.pack_polling_commands @property @@ -224,36 +218,38 @@ class PollingCoordinator(DataUpdateCoordinator): except ParseError: self.logger.warning("Got a parse exception...") - - # pack polling - for pack in range (1, self.bluetti_device.pack_num_max + 1): - # Set current pack number - await self.async_send_command( - self.bluetti_device.build_setter_command('pack_num', pack) - ) - - for command in self.bluetti_device.pack_polling_commands: - # Request & parse result for each pack - try: - body = command.parse_response( - await self.async_send_command(command) + + if len(self.bluetti_device.pack_polling_commands) > 0: + _LOGGER.debug("Polling battery packs") + # pack polling + for pack in range (1, self.bluetti_device.pack_num_max + 1): + # Set current pack number + await self.async_send_command( + self.bluetti_device.build_setter_command('pack_num', pack) ) - parsed = self.bluetti_device.parse( - command.starting_address, body - ) - self.logger.debug("Parsed data: %s", parsed) - packNo = parsed.get('pack_num') - if not isinstance(packNo, int) or packNo != pack: - self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", packNo, pack) - continue + for command in self.bluetti_device.pack_polling_commands: + # Request & parse result for each pack + try: + body = command.parse_response( + await self.async_send_command(command) + ) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + self.logger.debug("Parsed data: %s", parsed) - for key, value in parsed.items(): - parsed_data.update({key+str(pack):value}) + pack_number = parsed.get('pack_num') + if not isinstance(pack_number, int) or pack_number != pack: + self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", pack_number, pack) + continue + + for key, value in parsed.items(): + parsed_data.update({key+str(pack):value}) + + except ParseError: + self.logger.warning("Got a parse exception...") - except ParseError: - self.logger.warning("Got a parse exception...") - except TimeoutError: self.logger.debug("Polling timed out for device %s", mac_loggable(self._address)) return None diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 12579dc..0cf39f2 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -65,7 +65,7 @@ async def async_setup_entry( for pack in range (1, bluetti_device.pack_num_max + 1): for name, field in battery_pack_fields(pack).items(): all_fields.update({name+str(pack):field}) - + for field_key, field_config in all_fields.items(): if bluetti_device.has_field(field_key) or field_config.id_override is not None: category = None @@ -151,7 +151,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): response_data = self.coordinator.data.get(self._response_key) if response_data is None: - _LOGGER.debug("No data for available for (%s)",self._response_key) + _LOGGER.debug("No data for available for (%s)", self._response_key) self._attr_available = False return diff --git a/custom_components/bluetti_bt/switch.py b/custom_components/bluetti_bt/switch.py index 184cb55..62280e4 100644 --- a/custom_components/bluetti_bt/switch.py +++ b/custom_components/bluetti_bt/switch.py @@ -6,7 +6,7 @@ import asyncio import logging import async_timeout -from bleak import BleakClient, BleakError +from bleak import BleakError from homeassistant.components import bluetooth from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass @@ -33,7 +33,7 @@ from bluetti_mqtt.mqtt_client import ( ) from . import device_info as dev_info, get_unique_id -from .const import CONTROL_FIELDS, DATA_COORDINATOR, DATA_POLLING_RUNNING, DOMAIN, ADDITIONAL_DEVICE_FIELDS +from .const import CONTROL_FIELDS, DATA_COORDINATOR, DOMAIN, ADDITIONAL_DEVICE_FIELDS from .coordinator import PollingCoordinator, DummyDevice from .utils import mac_loggable, unique_id_loggable From 33fe91c2d2fb424a5913866722e73af8b560570c Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sat, 23 Sep 2023 19:45:02 +0200 Subject: [PATCH 11/18] added check if packs available --- custom_components/bluetti_bt/coordinator.py | 2 +- custom_components/bluetti_bt/sensor.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 534d686..75107cf 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -182,7 +182,7 @@ class PollingCoordinator(DataUpdateCoordinator): async with self.polling_lock: try: - async with async_timeout.timeout(30): + async with async_timeout.timeout(45): # Reconnect if not connected max_retries = 5 diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 0cf39f2..fdff6e5 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -60,11 +60,13 @@ async def async_setup_entry( all_fields = NORMAL_DEVICE_FIELDS all_fields.update(DC_INPUT_FIELDS) all_fields.update(ADDITIONAL_DEVICE_FIELDS) - # add pack fields for device - _LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max) - for pack in range (1, bluetti_device.pack_num_max + 1): - for name, field in battery_pack_fields(pack).items(): - all_fields.update({name+str(pack):field}) + + if len(bluetti_device.pack_polling_commands) > 0: + # add pack fields for device + _LOGGER.info("Device type(%s) pack_num_max(%s)", bluetti_device.type, bluetti_device.pack_num_max) + for pack in range (1, bluetti_device.pack_num_max + 1): + for name, field in battery_pack_fields(pack).items(): + all_fields.update({name+str(pack): field}) for field_key, field_config in all_fields.items(): if bluetti_device.has_field(field_key) or field_config.id_override is not None: From 7c211cde6f00510289f193235f7473faf3535722 Mon Sep 17 00:00:00 2001 From: Rui Melo Date: Wed, 13 Dec 2023 21:11:54 +0000 Subject: [PATCH 12/18] Create pt.json --- .../bluetti_bt/translations/pt.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 custom_components/bluetti_bt/translations/pt.json diff --git a/custom_components/bluetti_bt/translations/pt.json b/custom_components/bluetti_bt/translations/pt.json new file mode 100644 index 0000000..699c50e --- /dev/null +++ b/custom_components/bluetti_bt/translations/pt.json @@ -0,0 +1,16 @@ +{ + "options": { + "step": { + "init": { + "data": { + "use_controls": "Usar os comandos", + "persistent_conn": "Ligação presistente (necessario reninciar)", + "polling_interval": "Intervalo deatualização (necessario reninciar)" + } + } + }, + "abort": { + "invalid_interval": "Valor invalido . Tem de ser no minimo 5 segundos" + } + } +} From 4710a8183f981f960b83c4ed6c85716060b62dd8 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sun, 17 Dec 2023 16:55:41 +0100 Subject: [PATCH 13/18] replaced deploy script --- deploy.bat | 1 - deploy.sh | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 deploy.bat create mode 100755 deploy.sh diff --git a/deploy.bat b/deploy.bat deleted file mode 100644 index bf48c37..0000000 --- a/deploy.bat +++ /dev/null @@ -1 +0,0 @@ -robocopy .\custom_components\bluetti_bt T:\custom_components\bluetti_bt /E diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..0c2854c --- /dev/null +++ b/deploy.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +cp -r ./custom_components/* /run/user/1000/gvfs/smb-share:*/custom_components/ From 14f2219b94e19d4aae8dfed2bb7c1c9ea0bac87c Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sun, 17 Dec 2023 21:08:28 +0100 Subject: [PATCH 14/18] updated version number, added logging --- custom_components/bluetti_bt/coordinator.py | 7 ++++--- custom_components/bluetti_bt/manifest.json | 2 +- custom_components/bluetti_bt/sensor.py | 11 +++++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 75107cf..9aed097 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -155,6 +155,7 @@ class PollingCoordinator(DataUpdateCoordinator): self.bluetti_device = DummyDevice(bluetti_device) # Create client + self.logger.debug("Creating client") device = bluetooth.async_ble_device_from_address(hass, address) if device is None: self.logger.error("Device %s not available", mac_loggable(address)) @@ -195,7 +196,7 @@ class PollingCoordinator(DataUpdateCoordinator): if attempt == max_retries: raise # pass exception on max_retries attempt else: - self.logger.debug(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...") + self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...") await asyncio.sleep(2) # Attach notifier if needed @@ -220,7 +221,7 @@ class PollingCoordinator(DataUpdateCoordinator): self.logger.warning("Got a parse exception...") if len(self.bluetti_device.pack_polling_commands) > 0: - _LOGGER.debug("Polling battery packs") + self.logger.debug("Polling battery packs") # pack polling for pack in range (1, self.bluetti_device.pack_num_max + 1): # Set current pack number @@ -251,7 +252,7 @@ class PollingCoordinator(DataUpdateCoordinator): self.logger.warning("Got a parse exception...") except TimeoutError: - self.logger.debug("Polling timed out for device %s", mac_loggable(self._address)) + self.logger.warning("Polling timed out for device %s", mac_loggable(self._address)) return None except BleakError as err: self.logger.warning("Bleak error: %s", err) diff --git a/custom_components/bluetti_bt/manifest.json b/custom_components/bluetti_bt/manifest.json index da4e654..65b1a44 100644 --- a/custom_components/bluetti_bt/manifest.json +++ b/custom_components/bluetti_bt/manifest.json @@ -18,5 +18,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", "requirements": ["bluetti_mqtt==0.15.0"], - "version": "0.0.11" + "version": "2023.12.1" } diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index fdff6e5..428800e 100644 --- a/custom_components/bluetti_bt/sensor.py +++ b/custom_components/bluetti_bt/sensor.py @@ -143,9 +143,16 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): if self.coordinator.persistent_conn and not self.coordinator.client.is_connected: return + if self.coordinator.data is None: + _LOGGER.warning( + "Data from coordinator is None. Skipping update for %s", + unique_id_loggable(self._attr_unique_id) + ) + return + _LOGGER.debug("Updating state of %s", unique_id_loggable(self._attr_unique_id)) if not isinstance(self.coordinator.data, dict): - _LOGGER.debug( + _LOGGER.warning( "Invalid data from coordinator (sensor.%s)", unique_id_loggable(self._attr_unique_id) ) self._attr_available = False @@ -153,7 +160,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): response_data = self.coordinator.data.get(self._response_key) if response_data is None: - _LOGGER.debug("No data for available for (%s)", self._response_key) + _LOGGER.warning("No data for available for (%s)", self._response_key) self._attr_available = False return From 515a06dc3a26b8a1412069bddf843ddbcfc6bd7b Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Thu, 4 Jan 2024 15:14:36 +0100 Subject: [PATCH 15/18] added more config options --- custom_components/bluetti_bt/__init__.py | 6 ++-- custom_components/bluetti_bt/config_flow.py | 29 ++++++++++++++++++- custom_components/bluetti_bt/const.py | 2 ++ custom_components/bluetti_bt/coordinator.py | 14 ++++++--- .../bluetti_bt/translations/de.json | 8 +++-- .../bluetti_bt/translations/en.json | 8 +++-- deploy.sh | 4 ++- 7 files changed, 59 insertions(+), 12 deletions(-) diff --git a/custom_components/bluetti_bt/__init__.py b/custom_components/bluetti_bt/__init__.py index 300ef90..45b7765 100644 --- a/custom_components/bluetti_bt/__init__.py +++ b/custom_components/bluetti_bt/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_PERSISTENT_CONN, CONF_POLLING_INTERVAL, DATA_COORDINATOR, DOMAIN, MANUFACTURER, CONF_USE_CONTROLS, DATA_POLLING_RUNNING +from .const import CONF_MAX_RETRIES, CONF_PERSISTENT_CONN, CONF_POLLING_INTERVAL, CONF_POLLING_TIMEOUT, DATA_COORDINATOR, DOMAIN, MANUFACTURER, CONF_USE_CONTROLS, DATA_POLLING_RUNNING from .coordinator import PollingCoordinator PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -27,6 +27,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: use_controls = entry.data.get(CONF_USE_CONTROLS) polling_interval = entry.data.get(CONF_POLLING_INTERVAL, 20) persistent_conn = entry.data.get(CONF_PERSISTENT_CONN, False) + polling_timeout = entry.data.get(CONF_POLLING_TIMEOUT, 45) + max_retries = entry.data.get(CONF_MAX_RETRIES, 5) if address is None: return False @@ -40,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id].setdefault(DATA_POLLING_RUNNING, False) # Create coordinator for polling - coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn) + coordinator = PollingCoordinator(hass, address, device_name, polling_interval, persistent_conn, polling_timeout, max_retries) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id].setdefault(DATA_COORDINATOR, coordinator) diff --git a/custom_components/bluetti_bt/config_flow.py b/custom_components/bluetti_bt/config_flow.py index 975a068..61d78c9 100644 --- a/custom_components/bluetti_bt/config_flow.py +++ b/custom_components/bluetti_bt/config_flow.py @@ -17,7 +17,14 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector from . import get_type_by_bt_name -from .const import CONF_PERSISTENT_CONN, CONF_POLLING_INTERVAL, DOMAIN, CONF_USE_CONTROLS +from .const import ( + CONF_MAX_RETRIES, + CONF_PERSISTENT_CONN, + CONF_POLLING_INTERVAL, + CONF_POLLING_TIMEOUT, + CONF_USE_CONTROLS, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -112,6 +119,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): # Validate update interval if user_input[CONF_POLLING_INTERVAL] < 5: return self.async_abort(reason="invalid_interval") + + # Validate update timeout + if user_input[CONF_POLLING_TIMEOUT] < 1: + return self.async_abort(reason="invalid_timeout") + + # Validate max retries + if user_input[CONF_MAX_RETRIES] < 1: + return self.async_abort(reason="invalid_retries") changed = self.hass.config_entries.async_update_entry( self.config_entry, @@ -121,6 +136,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_CONTROLS: user_input[CONF_USE_CONTROLS], CONF_PERSISTENT_CONN: user_input[CONF_PERSISTENT_CONN], CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL], + CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT], + CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES], }, }, ) @@ -135,6 +152,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_CONTROLS: user_input[CONF_USE_CONTROLS], CONF_PERSISTENT_CONN: user_input[CONF_PERSISTENT_CONN], CONF_POLLING_INTERVAL: user_input[CONF_POLLING_INTERVAL], + CONF_POLLING_TIMEOUT: user_input[CONF_POLLING_TIMEOUT], + CONF_MAX_RETRIES: user_input[CONF_MAX_RETRIES], }, ) @@ -154,6 +173,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_POLLING_INTERVAL, default=self.config_entry.data.get(CONF_POLLING_INTERVAL, 20), ): int, + vol.Required( + CONF_POLLING_TIMEOUT, + default=self.config_entry.data.get(CONF_POLLING_TIMEOUT, 45), + ): int, + vol.Required( + CONF_MAX_RETRIES, + default=self.config_entry.data.get(CONF_MAX_RETRIES, 5), + ): int, } ), ) diff --git a/custom_components/bluetti_bt/const.py b/custom_components/bluetti_bt/const.py index 202cf06..75c2406 100644 --- a/custom_components/bluetti_bt/const.py +++ b/custom_components/bluetti_bt/const.py @@ -9,6 +9,8 @@ CONF_OPTIONS = "options" CONF_USE_CONTROLS = "use_controls" CONF_PERSISTENT_CONN = "persistent_conn" CONF_POLLING_INTERVAL = "polling_interval" +CONF_POLLING_TIMEOUT = "polling_timeout" +CONF_MAX_RETRIES = "max_retries" DATA_COORDINATOR = "coordinator" DATA_POLLING_RUNNING = "polling_running" diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 9aed097..6f630a9 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -135,6 +135,8 @@ class PollingCoordinator(DataUpdateCoordinator): device_name: str, polling_interval: int, persistent_conn: bool, + polling_timeout: int, + max_retries: int, ): """Initialize coordinator.""" super().__init__( @@ -150,6 +152,8 @@ class PollingCoordinator(DataUpdateCoordinator): self.notify_response = bytearray() bluetti_device = build_device(address, device_name) self.persistent_conn = persistent_conn + self.polling_timeout = polling_timeout + self.max_retries = max_retries # Add or modify device fields self.bluetti_device = DummyDevice(bluetti_device) @@ -183,17 +187,16 @@ class PollingCoordinator(DataUpdateCoordinator): async with self.polling_lock: try: - async with async_timeout.timeout(45): + async with async_timeout.timeout(self.polling_timeout): # Reconnect if not connected - max_retries = 5 - for attempt in range(1,max_retries + 1): + for attempt in range(1, self.max_retries + 1): try: if not self.client.is_connected: await self.client.connect() break except Exception as e: - if attempt == max_retries: + if attempt == self.max_retries or attempt == 1: raise # pass exception on max_retries attempt else: self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...") @@ -211,6 +214,7 @@ class PollingCoordinator(DataUpdateCoordinator): body = command.parse_response( await self.async_send_command(command) ) + self.logger.debug("Raw data: %s", body) parsed = self.bluetti_device.parse( command.starting_address, body ) @@ -265,6 +269,7 @@ class PollingCoordinator(DataUpdateCoordinator): self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False # Pass data back to sensors + # TODO No data if non persistent connection return parsed_data async def async_send_command(self, command: ReadHoldingRegisters) -> bytes: @@ -313,6 +318,7 @@ class PollingCoordinator(DataUpdateCoordinator): # Ignore notifications we don't expect if not self.notify_future or self.notify_future.done(): + _LOGGER.warning("Unexpected notification") return # If something went wrong, we might get weird data. diff --git a/custom_components/bluetti_bt/translations/de.json b/custom_components/bluetti_bt/translations/de.json index 4e3ea74..7093834 100644 --- a/custom_components/bluetti_bt/translations/de.json +++ b/custom_components/bluetti_bt/translations/de.json @@ -5,12 +5,16 @@ "data": { "use_controls": "Steuerung aktivieren (auf eigenes Risiko)", "persistent_conn": "Dauerhafte Verbindung (Neustart erforderlich)", - "polling_interval": "Datenabruf-Intervall in Sekunden (Neustart erforderlich)" + "polling_interval": "Datenabruf-Intervall in Sekunden (Neustart erforderlich)", + "polling_timeout": "Datenabruf-Timeout in Sekunden (Neustart erforderlich)", + "max_retries": "Maximale Verbindungsversuche (Neustart erforderlich)" } } }, "abort": { - "invalid_interval": "Ungültiger Datenabruf-Intervall. Verwende 5 Sekunden oder mehr" + "invalid_interval": "Ungültiger Datenabruf-Intervall. Verwende 5 Sekunden oder mehr", + "invalid_timeout": "Ungültiger Datenabruf-Timeout. Verwende 1 Sekunde oder mehr", + "invalid_retries": "Ungültige maximale Verbindungsversuche. Verwende 1 oder mehr" } } } \ No newline at end of file diff --git a/custom_components/bluetti_bt/translations/en.json b/custom_components/bluetti_bt/translations/en.json index 055b9a9..1229863 100644 --- a/custom_components/bluetti_bt/translations/en.json +++ b/custom_components/bluetti_bt/translations/en.json @@ -5,12 +5,16 @@ "data": { "use_controls": "Use controls (use at own risk)", "persistent_conn": "Persistent connection (restart required)", - "polling_interval": "Polling interval in seconds (restart required)" + "polling_interval": "Polling interval in seconds (restart required)", + "polling_timeout": "Polling timeout in seconds (restart required)", + "max_retries": "Maximum amount of connection retries (restart required)" } } }, "abort": { - "invalid_interval": "Invalid polling interval. Use 5 seconds or more" + "invalid_interval": "Invalid polling interval. Use 5 seconds or more", + "invalid_timeout": "Invalid polling timeout. Use 1 second or more", + "invalid_retries": "Invalid max retries. Use 1 or more" } } } \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index 0c2854c..aa6ddcd 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,3 +1,5 @@ #!/bin/sh -cp -r ./custom_components/* /run/user/1000/gvfs/smb-share:*/custom_components/ +# This script is only for development purposes + +sudo cp -r ./custom_components/* /var/ha_config/custom_components/ From 98f004dad1f91d44ba1afaebc1d8c43e0df526aa Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Thu, 4 Jan 2024 15:16:15 +0100 Subject: [PATCH 16/18] revert to previous version number --- custom_components/bluetti_bt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bluetti_bt/manifest.json b/custom_components/bluetti_bt/manifest.json index 65b1a44..da4e654 100644 --- a/custom_components/bluetti_bt/manifest.json +++ b/custom_components/bluetti_bt/manifest.json @@ -18,5 +18,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Patrick762/hassio-bluetti-bt/issues", "requirements": ["bluetti_mqtt==0.15.0"], - "version": "2023.12.1" + "version": "0.0.11" } From dda0d475f4714d9ccffea74d1d9275a6266a75bc Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Thu, 4 Jan 2024 15:39:02 +0100 Subject: [PATCH 17/18] stop notifier before disconnect --- custom_components/bluetti_bt/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index 6f630a9..ef87053 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -264,12 +264,14 @@ class PollingCoordinator(DataUpdateCoordinator): finally: # Disconnect if connection not persistant if not self.persistent_conn: + if self.has_notifier: + await self.client.stop_notify(BluetoothClient.NOTIFY_UUID) + self.has_notifier = False await self.client.disconnect() self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False # Pass data back to sensors - # TODO No data if non persistent connection return parsed_data async def async_send_command(self, command: ReadHoldingRegisters) -> bytes: From b7c162aaef4effdd7c1d60707e7f670f23c94767 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Thu, 4 Jan 2024 16:02:49 +0100 Subject: [PATCH 18/18] updated readme --- README.md | 3 +-- custom_components/bluetti_bt/__init__.py | 12 +++++++++++- custom_components/bluetti_bt/coordinator.py | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e9d402a..ce86d3d 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ After the installation, you can use this button to install the integration: - EP600 (tested) ### Available sensors: -All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)) -excluding battery pack fields. +All sensors which are available in bluetti_mqtt (Based on [this file](https://github.com/warhammerkid/bluetti_mqtt/blob/main/bluetti_mqtt/mqtt_client.py)). ### Available controls: If enabled in the Integration options (you need to reload the integration if you change this option): diff --git a/custom_components/bluetti_bt/__init__.py b/custom_components/bluetti_bt/__init__.py index 45b7765..8b69d58 100644 --- a/custom_components/bluetti_bt/__init__.py +++ b/custom_components/bluetti_bt/__init__.py @@ -12,7 +12,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_MAX_RETRIES, CONF_PERSISTENT_CONN, CONF_POLLING_INTERVAL, CONF_POLLING_TIMEOUT, DATA_COORDINATOR, DOMAIN, MANUFACTURER, CONF_USE_CONTROLS, DATA_POLLING_RUNNING +from .const import ( + CONF_MAX_RETRIES, + CONF_PERSISTENT_CONN, + CONF_POLLING_INTERVAL, + CONF_POLLING_TIMEOUT, + CONF_USE_CONTROLS, + DATA_COORDINATOR, + DATA_POLLING_RUNNING, + DOMAIN, + MANUFACTURER, +) from .coordinator import PollingCoordinator PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] diff --git a/custom_components/bluetti_bt/coordinator.py b/custom_components/bluetti_bt/coordinator.py index ef87053..8239c64 100644 --- a/custom_components/bluetti_bt/coordinator.py +++ b/custom_components/bluetti_bt/coordinator.py @@ -231,7 +231,7 @@ class PollingCoordinator(DataUpdateCoordinator): # Set current pack number await self.async_send_command( self.bluetti_device.build_setter_command('pack_num', pack) - ) + ) for command in self.bluetti_device.pack_polling_commands: # Request & parse result for each pack @@ -311,7 +311,7 @@ class PollingCoordinator(DataUpdateCoordinator): self.logger.warning( "Needed to disconnect due to error: %s (This can also be the case if you used device controls)", err ) - + # caught an exception, return empty bytes object return bytes()