From 5434f30743b3345634d5b5a6ede8adfd1b9e70d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Jald=C3=A9n?= Date: Wed, 25 Sep 2024 11:54:28 +0200 Subject: [PATCH] Support for the PUREi9 Robot Vacuum (#145) * Implemented support for the PUREi9 vacuum * Cleaned up unessessary changes * Cleaned up unessessary changes * Fixed bug introduces by cleanup * Fixed error that kept vacuum battery from updating * Added labels to all 14 robot states * Reimplemented battery state similar to speed range to allow for model dependent ranges --- custom_components/wellbeing/__init__.py | 8 +- custom_components/wellbeing/api.py | 83 ++++++++++++- custom_components/wellbeing/vacuum.py | 151 ++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 custom_components/wellbeing/vacuum.py diff --git a/custom_components/wellbeing/__init__.py b/custom_components/wellbeing/__init__.py index 9e8f69c..a4c558e 100644 --- a/custom_components/wellbeing/__init__.py +++ b/custom_components/wellbeing/__init__.py @@ -23,7 +23,13 @@ from .const import DOMAIN _LOGGER: logging.Logger = logging.getLogger(__package__) -PLATFORMS = [Platform.SENSOR, Platform.FAN, Platform.BINARY_SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.SENSOR, + Platform.FAN, + Platform.BINARY_SENSOR, + Platform.SWITCH, + Platform.VACUUM, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/custom_components/wellbeing/api.py b/custom_components/wellbeing/api.py index 54ad477..8563599 100644 --- a/custom_components/wellbeing/api.py +++ b/custom_components/wellbeing/api.py @@ -46,6 +46,8 @@ class Model(str, Enum): AX5 = "AX5" AX7 = "AX7" AX9 = "AX9" + PUREi9 = "PUREi9" + class WorkMode(str, Enum): OFF = "PowerOff" @@ -109,6 +111,13 @@ def __init__(self, name, attr) -> None: super().__init__(name, attr) +class ApplianceVacuum(ApplianceEntity): + entity_type: int = Platform.VACUUM + + def __init__(self, name, attr) -> None: + super().__init__(name, attr) + + class ApplianceBinary(ApplianceEntity): entity_type: int = Platform.BINARY_SENSOR @@ -167,7 +176,7 @@ def _create_entities(data): name="State", attr="State", device_class=SensorDeviceClass.ENUM, - entity_category=EntityCategory.DIAGNOSTIC + entity_category=EntityCategory.DIAGNOSTIC, ), ApplianceBinary( name="PM Sensor State", @@ -191,6 +200,18 @@ def _create_entities(data): ), ] + purei9_entities = [ + ApplianceSensor( + name="Dustbin Status", + attr="dustbinStatus", + device_class=SensorDeviceClass.ENUM, + ), + ApplianceVacuum( + name="Vacuum", + attr="robotStatus", + ), + ] + common_entities = [ ApplianceFan( name="Fan Speed", @@ -266,7 +287,13 @@ def _create_entities(data): ApplianceBinary(name="Safety Lock", attr="SafetyLock", device_class=BinarySensorDeviceClass.LOCK), ] - return common_entities + a9_entities + a7_entities + pure500_entities + return ( + common_entities + + a9_entities + + a7_entities + + pure500_entities + + purei9_entities + ) def get_entity(self, entity_type, entity_attr): return next( @@ -284,7 +311,12 @@ def set_mode(self, mode: WorkMode): def setup(self, data, capabilities): self.firmware = data.get("FrmVer_NIU") - self.mode = WorkMode(data.get("Workmode")) + if "Workmode" in data: + self.mode = WorkMode(data.get("Workmode")) + if "powerMode" in data: + self.power_mode = data.get("powerMode") + if "batteryStatus" in data: + self.battery_status = data.get("batteryStatus") self.capabilities = capabilities self.entities = [entity.setup(data) for entity in Appliance._create_entities(data) if entity.attr in data] @@ -326,6 +358,23 @@ def speed_range(self) -> tuple[int, int]: return 0, 0 + @property + def battery_range(self) -> tuple[int, int]: + if self.model == Model.PUREi9: + return 2, 6 # Do not include lowest value of 1 to make this mean empty (0%) battery + + return 0, 0 + + @property + def vacuum_fan_speeds(self) -> dict[int, str]: + if self.model == Model.PUREi9: + return { + 1: "Quiet", + 2: "Smart", + 3: "Power", + } + return {} + class Appliances: def __init__(self, appliances) -> None: @@ -359,7 +408,10 @@ async def async_get_appliances(self) -> Appliances: _LOGGER.debug(f"Appliance initial: {appliance.initial_data}") _LOGGER.debug(f"Appliance state: {appliance.state}") - if appliance.device_type != "AIR_PURIFIER": + if ( + appliance.device_type != "AIR_PURIFIER" + and appliance.device_type != "ROBOTIC_VACUUM_CLEANER" + ): continue app = Appliance(appliance_name, appliance_id, model_name) @@ -370,12 +422,35 @@ async def async_get_appliances(self) -> Appliances: data = appliance.state data["status"] = appliance.state_data.get("status", "unknown") data["connectionState"] = appliance.state_data.get("connectionState", "unknown") + app.setup(data, appliance.capabilities_data) found_appliances[app.pnc_id] = app return Appliances(found_appliances) + async def command_vacuum(self, pnc_id: str, cmd: str): + data = {"CleaningCommand": cmd} + appliance = self._api_appliances.get(pnc_id, None) + if appliance is None: + _LOGGER.error( + f"Failed to send vacuum command for appliance with id {pnc_id}" + ) + return + result = await appliance.send_command(data) + _LOGGER.debug(f"Vacuum command: {result}") + + async def set_vacuum_power_mode(self, pnc_id: str, mode: int): + data = { + "powerMode": mode + } # Not the right formatting. Disable FAN_SPEEDS until this is figured out + appliance = self._api_appliances.get(pnc_id, None) + if appliance is None: + _LOGGER.error(f"Failed to set feature {feature} for appliance with id {pnc_id}") + return + result = await appliance.send_command(data) + _LOGGER.debug(f"Set Vacuum Power Mode: {result}") + async def set_fan_speed(self, pnc_id: str, level: int): data = {"Fanspeed": level} appliance = self._api_appliances.get(pnc_id, None) diff --git a/custom_components/wellbeing/vacuum.py b/custom_components/wellbeing/vacuum.py new file mode 100644 index 0000000..c04f1da --- /dev/null +++ b/custom_components/wellbeing/vacuum.py @@ -0,0 +1,151 @@ +"""Vacuum platform for Wellbeing.""" + +import asyncio +import logging +import math + +from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_PAUSED, + STATE_RETURNING, + STATE_IDLE, + STATE_DOCKED, + STATE_ERROR, +) +from homeassistant.const import Platform +from homeassistant.util.percentage import ranged_value_to_percentage + +from . import WellbeingDataUpdateCoordinator +from .const import DOMAIN +from .entity import WellbeingEntity + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +SUPPORTED_FEATURES = ( + VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.BATTERY +) + +VACUUM_STATES = { + 1: STATE_CLEANING, # Regular Cleaning + 2: STATE_PAUSED, + 3: STATE_CLEANING, # Stop cleaning + 4: STATE_PAUSED, # Pause Spot cleaning + 5: STATE_RETURNING, + 6: STATE_PAUSED, # Paused returning + 7: STATE_RETURNING, # Returning for pitstop + 8: STATE_PAUSED, # Paused returning for pitstop + 9: STATE_DOCKED, # Charging + 10: STATE_IDLE, + 11: STATE_ERROR, + 12: STATE_DOCKED, # Pitstop + 13: STATE_IDLE, # Manual stearing + 14: STATE_IDLE, # Firmware upgrading +} + +VACUUM_CHARGING_STATE = 9 # For selecting battery icon + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup vacuum platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + appliances = coordinator.data.get("appliances", None) + + if appliances is not None: + for pnc_id, appliance in appliances.appliances.items(): + async_add_devices( + [ + WellbeingVacuum( + coordinator, entry, pnc_id, entity.entity_type, entity.attr + ) + for entity in appliance.entities + if entity.entity_type == Platform.VACUUM + ] + ) + + +class WellbeingVacuum(WellbeingEntity, StateVacuumEntity): + """wellbeing Sensor class.""" + + def __init__( + self, + coordinator: WellbeingDataUpdateCoordinator, + config_entry, + pnc_id, + entity_type, + entity_attr, + ): + super().__init__(coordinator, config_entry, pnc_id, entity_type, entity_attr) + self._fan_speeds = self.get_appliance.vacuum_fan_speeds + + @property + def _battery_range(self) -> tuple[int, int]: + return self.get_appliance.battery_range + + @property + def supported_features(self) -> int: + return SUPPORTED_FEATURES + + @property + def state(self): + """Return the state of the vacuum.""" + return VACUUM_STATES.get(self.get_entity.state, STATE_ERROR) + + @property + def battery_level(self): + """Return the battery level of the vacuum.""" + return ranged_value_to_percentage(self._battery_range, self.get_appliance.battery_status) + + @property + def battery_icon(self): + """Return the battery icon of the vacuum based on the battery level.""" + level = self.battery_level + charging = self.get_entity.state == VACUUM_CHARGING_STATE + level = 10*round(level / 10) # Round level to nearest 10 for icon selection + # Special cases given available icons + if level == 100 and charging: + return "mdi:battery-charging-100" + if level == 100 and not charging: + return "mdi:battery" + if level == 0 and charging: + return "mdi:battery-charging-outline" + if level == 0 and not charging: + return "mdi:battery-alert-variant-outline" + # General case + if level > 0 and level < 100: + return "mdi:battery-" + ("charging-" if charging else "") + f"{level}" + else: + return "mdi:battery-unknown" + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + return self._fan_speeds.get(self.get_appliance.power_mode, "Unknown") + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return list(self._fan_speeds.values()) + + async def async_start(self): + await self.api.command_vacuum(self.pnc_id, "play") + + async def async_stop(self): + await self.api.command_vacuum(self.pnc_id, "stop") + + async def async_pause(self): + await self.api.command_vacuum(self.pnc_id, "pause") + + async def async_return_to_base(self): + await self.api.command_vacuum(self.pnc_id, "home") + + async def async_set_fan_speed(self, fan_speed: str): + """Set the fan speed of the vacuum cleaner.""" + for mode, name in FAN_SPEEDS.items(): + if name == fan_speed: + await self.api.set_vacuum_power_mode(self.pnc_id, mode) + break