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 107be83..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_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, + CONF_USE_CONTROLS, + DATA_COORDINATOR, + DATA_POLLING_RUNNING, + DOMAIN, + MANUFACTURER, +) from .coordinator import PollingCoordinator PLATFORMS: [Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -27,11 +37,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) - - # 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 + polling_timeout = entry.data.get(CONF_POLLING_TIMEOUT, 45) + max_retries = entry.data.get(CONF_MAX_RETRIES, 5) if address is None: return False @@ -45,7 +52,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/binary_sensor.py b/custom_components/bluetti_bt/binary_sensor.py index b177e1c..a52a269 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 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): _LOGGER.debug( diff --git a/custom_components/bluetti_bt/config_flow.py b/custom_components/bluetti_bt/config_flow.py index d487271..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, DATA_COORDINATOR, 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 acbf2ab..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" @@ -79,6 +81,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 f8ded43..8239c64 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,13 +80,14 @@ 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 + @property + def pack_num_max(self): + return self._parent.pack_num_max + @property def polling_commands(self) -> List[ReadHoldingRegisters]: if self.type == "EP600": @@ -90,29 +98,19 @@ 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]: - return self._parent.pack_logging_commands + if self.type == "EP600": + return [] + return self._parent.pack_polling_commands @property def logging_commands(self) -> List[ReadHoldingRegisters]: @@ -137,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__( @@ -152,17 +152,23 @@ 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) # 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)) 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. @@ -179,102 +185,142 @@ class PollingCoordinator(DataUpdateCoordinator): parsed_data: dict = {} - # Connect to device + async with self.polling_lock: + try: + async with async_timeout.timeout(self.polling_timeout): + + # Reconnect if not connected + for attempt in range(1, self.max_retries + 1): + try: + if not self.client.is_connected: + await self.client.connect() + break + except Exception as e: + if attempt == self.max_retries or attempt == 1: + raise # pass exception on max_retries attempt + else: + self.logger.warning(f"Connect unsucessful (attempt {attempt}): {e}. Retrying...") + await asyncio.sleep(2) + + # Attach notifier if needed + if not self.has_notifier: + await self.client.start_notify( + BluetoothClient.NOTIFY_UUID, self._notification_handler + ) + self.has_notifier = True + + for command in self.bluetti_device.polling_commands: + try: + body = command.parse_response( + await self.async_send_command(command) + ) + self.logger.debug("Raw data: %s", body) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + self.logger.debug("Parsed data: %s", parsed) + parsed_data.update(parsed) + + except ParseError: + self.logger.warning("Got a parse exception...") + + if len(self.bluetti_device.pack_polling_commands) > 0: + self.logger.debug("Polling battery packs") + # pack polling + for pack in range (1, self.bluetti_device.pack_num_max + 1): + # Set current pack number + await self.async_send_command( + self.bluetti_device.build_setter_command('pack_num', pack) + ) + + for command in self.bluetti_device.pack_polling_commands: + # Request & parse result for each pack + try: + body = command.parse_response( + await self.async_send_command(command) + ) + parsed = self.bluetti_device.parse( + command.starting_address, body + ) + self.logger.debug("Parsed data: %s", parsed) + + pack_number = parsed.get('pack_num') + if not isinstance(pack_number, int) or pack_number != pack: + self.logger.debug("Parsed pack_num(%s) does not match expected '%s'", pack_number, pack) + continue + + for key, value in parsed.items(): + parsed_data.update({key+str(pack):value}) + + except ParseError: + self.logger.warning("Got a parse exception...") + + except TimeoutError: + self.logger.warning("Polling timed out for device %s", mac_loggable(self._address)) + return None + except BleakError as err: + self.logger.warning("Bleak error: %s", err) + return None + finally: + # Disconnect if connection not persistant + if not self.persistent_conn: + if self.has_notifier: + await self.client.stop_notify(BluetoothClient.NOTIFY_UUID) + self.has_notifier = False + await self.client.disconnect() + + self.hass.data[DOMAIN][self.config_entry.entry_id][DATA_POLLING_RUNNING] = False + + # Pass data back to sensors + return parsed_data + + async def async_send_command(self, command: ReadHoldingRegisters) -> bytes: + """Send command and return response""" try: - async with async_timeout.timeout(20): - await self.client.connect() + # Prepare to make request + self.current_command = command + self.notify_future = self.hass.loop.create_future() + self.notify_response = bytearray() + + # Make request + self.logger.debug("Requesting %s", command) + await self.client.write_gatt_char( + BluetoothClient.WRITE_UUID, bytes(command) + ) + + # Wait for response + res = await asyncio.wait_for( + self.notify_future, timeout=BluetoothClient.RESPONSE_TIMEOUT + ) + + # Process data + self.logger.debug("Got %s bytes", len(res)) + return cast(bytes, res) - 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 + 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 + ) - try: - async with async_timeout.timeout(15): - - # 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: - # 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) - 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 - ) - 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 + # caught an exception, return empty bytes object + return bytes() def _notification_handler(self, _sender: int, data: bytearray): """Handle bt data.""" # Ignore notifications we don't expect if not self.notify_future or self.notify_future.done(): + _LOGGER.warning("Unexpected notification") return # If something went wrong, we might get weird data. 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" } diff --git a/custom_components/bluetti_bt/sensor.py b/custom_components/bluetti_bt/sensor.py index 9721f70..428800e 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,16 @@ async def async_setup_entry( all_fields = NORMAL_DEVICE_FIELDS all_fields.update(DC_INPUT_FIELDS) all_fields.update(ADDITIONAL_DEVICE_FIELDS) + + 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): + 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 @@ -130,9 +139,20 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + + 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 @@ -140,6 +160,7 @@ class BluettiSensor(CoordinatorEntity, SensorEntity): response_data = self.coordinator.data.get(self._response_key) if response_data is None: + _LOGGER.warning("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 6e4d5f0..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 @@ -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 @@ -114,6 +117,10 @@ class BluettiSwitch(CoordinatorEntity, SwitchEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + + 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): _LOGGER.error( @@ -152,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() 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/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" + } + } +} 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..aa6ddcd --- /dev/null +++ b/deploy.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# This script is only for development purposes + +sudo cp -r ./custom_components/* /var/ha_config/custom_components/