diff --git a/.coveragerc b/.coveragerc index 2b2fcc1ed61..f898fbe7c77 100644 --- a/.coveragerc +++ b/.coveragerc @@ -597,6 +597,11 @@ omit = homeassistant/components/ripple/sensor.py homeassistant/components/rocketchat/notify.py homeassistant/components/roku/remote.py + homeassistant/components/roomba/binary_sensor.py + homeassistant/components/roomba/braava.py + homeassistant/components/roomba/irobot_base.py + homeassistant/components/roomba/roomba.py + homeassistant/components/roomba/sensor.py homeassistant/components/roomba/vacuum.py homeassistant/components/route53/* homeassistant/components/rova/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 2c4c3c4c70f..5ed043bf176 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -317,7 +317,7 @@ homeassistant/components/rfxtrx/* @danielhiversen homeassistant/components/ring/* @balloob homeassistant/components/rmvtransport/* @cgtobi homeassistant/components/roku/* @ctalkington -homeassistant/components/roomba/* @pschmitt @cyr-ius +homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn homeassistant/components/safe_mode/* @home-assistant/core homeassistant/components/saj/* @fredericvl homeassistant/components/salt/* @bjornorri diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 2b4582610e1..28092f96477 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -96,6 +96,7 @@ async def async_setup_entry(hass, config_entry): continuous=config_entry.options[CONF_CONTINUOUS], delay=config_entry.options[CONF_DELAY], ) + roomba.exclude = "wifistat" # ignore wifistat to avoid unnecessary updates try: if not await async_connect_or_timeout(hass, roomba): diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index 4ed3ab02418..0cc4313b37b 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import BinarySensorDevice from . import roomba_reported_state from .const import BLID, DOMAIN, ROOMBA_SESSION +from .irobot_base import IRobotEntity _LOGGER = logging.getLogger(__name__) @@ -17,23 +18,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): status = roomba_reported_state(roomba).get("bin", {}) if "full" in status: roomba_vac = RoombaBinStatus(roomba, blid) + roomba_vac.register_callback() async_add_entities([roomba_vac], True) -class RoombaBinStatus(BinarySensorDevice): +class RoombaBinStatus(IRobotEntity, BinarySensorDevice): """Class to hold Roomba Sensor basic info.""" ICON = "mdi:delete-variant" - def __init__(self, roomba, blid): - """Initialize the sensor object.""" - self.vacuum = roomba - self.vacuum_state = roomba_reported_state(roomba) - self._blid = blid - self._name = self.vacuum_state.get("name") - self._identifier = f"roomba_{self._blid}" - self._bin_status = None - @property def name(self): """Return the name of the sensor.""" @@ -52,23 +45,8 @@ class RoombaBinStatus(BinarySensorDevice): @property def state(self): """Return the state of the sensor.""" - return self._bin_status - - @property - def device_info(self): - """Return the device info of the vacuum cleaner.""" - return { - "identifiers": {(DOMAIN, self._identifier)}, - "name": str(self._name), - } - - async def async_update(self): - """Return the update info of the vacuum cleaner.""" - # No data, no update - if not self.vacuum.master_state: - _LOGGER.debug("Roomba %s has no data yet. Skip update", self.name) - return - self._bin_status = ( + bin_status = ( roomba_reported_state(self.vacuum).get("bin", {}).get("full", False) ) - _LOGGER.debug("Update Full Bin status from the vacuum: %s", self._bin_status) + _LOGGER.debug("Update Full Bin status from the vacuum: %s", bin_status) + return bin_status diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py new file mode 100644 index 00000000000..1a3d106bf80 --- /dev/null +++ b/homeassistant/components/roomba/braava.py @@ -0,0 +1,135 @@ +"""Class for Braava devices.""" +import logging + +from homeassistant.components.vacuum import SUPPORT_FAN_SPEED + +from .irobot_base import SUPPORT_IROBOT, IRobotVacuum + +_LOGGER = logging.getLogger(__name__) + +ATTR_DETECTED_PAD = "detected_pad" +ATTR_LID_CLOSED = "lid_closed" +ATTR_TANK_PRESENT = "tank_present" +ATTR_TANK_LEVEL = "tank_level" +ATTR_PAD_WETNESS = "spray_amount" + +OVERLAP_STANDARD = 67 +OVERLAP_DEEP = 85 +OVERLAP_EXTENDED = 25 +MOP_STANDARD = "Standard" +MOP_DEEP = "Deep" +MOP_EXTENDED = "Extended" +BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED] +BRAAVA_SPRAY_AMOUNT = [1, 2, 3] + +# Braava Jets can set mopping behavior through fanspeed +SUPPORT_BRAAVA = SUPPORT_IROBOT | SUPPORT_FAN_SPEED + + +class BraavaJet(IRobotVacuum): + """Braava Jet.""" + + def __init__(self, roomba, blid): + """Initialize the Roomba handler.""" + super().__init__(roomba, blid) + + # Initialize fan speed list + speed_list = [] + for behavior in BRAAVA_MOP_BEHAVIORS: + for spray in BRAAVA_SPRAY_AMOUNT: + speed_list.append(f"{behavior}-{spray}") + self._speed_list = speed_list + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_BRAAVA + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + # Mopping behavior and spray amount as fan speed + rank_overlap = self.vacuum_state.get("rankOverlap", {}) + behavior = None + if rank_overlap == OVERLAP_STANDARD: + behavior = MOP_STANDARD + elif rank_overlap == OVERLAP_DEEP: + behavior = MOP_DEEP + elif rank_overlap == OVERLAP_EXTENDED: + behavior = MOP_EXTENDED + pad_wetness = self.vacuum_state.get("padWetness", {}) + # "disposable" and "reusable" values are always the same + pad_wetness_value = pad_wetness.get("disposable") + return f"{behavior}-{pad_wetness_value}" + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return self._speed_list + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + try: + split = fan_speed.split("-", 1) + behavior = split[0] + spray = int(split[1]) + if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: + behavior = behavior.capitalize() + except IndexError: + _LOGGER.error( + "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", + fan_speed, + ) + return + except ValueError: + _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1]) + return + if behavior not in BRAAVA_MOP_BEHAVIORS: + _LOGGER.error( + "Mop behavior error: expected one of %s, got '%s'", + str(BRAAVA_MOP_BEHAVIORS), + behavior, + ) + return + if spray not in BRAAVA_SPRAY_AMOUNT: + _LOGGER.error( + "Spray amount error: expected one of %s, got '%d'", + str(BRAAVA_SPRAY_AMOUNT), + spray, + ) + return + + overlap = 0 + if behavior == MOP_STANDARD: + overlap = OVERLAP_STANDARD + elif behavior == MOP_DEEP: + overlap = OVERLAP_DEEP + else: + overlap = OVERLAP_EXTENDED + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "rankOverlap", overlap + ) + await self.hass.async_add_executor_job( + self.vacuum.set_preference, + "padWetness", + {"disposable": spray, "reusable": spray}, + ) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + state_attrs = super().device_state_attributes + + # Get Braava state + state = self.vacuum_state + detected_pad = state.get("detectedPad") + mop_ready = state.get("mopReady", {}) + lid_closed = mop_ready.get("lidClosed") + tank_present = mop_ready.get("tankPresent") + tank_level = state.get("tankLvl") + state_attrs[ATTR_DETECTED_PAD] = detected_pad + state_attrs[ATTR_LID_CLOSED] = lid_closed + state_attrs[ATTR_TANK_PRESENT] = tank_present + state_attrs[ATTR_TANK_LEVEL] = tank_level + + return state_attrs diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py new file mode 100644 index 00000000000..54338114c20 --- /dev/null +++ b/homeassistant/components/roomba/irobot_base.py @@ -0,0 +1,250 @@ +"""Base class for iRobot devices.""" +import asyncio +import logging + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + SUPPORT_BATTERY, + SUPPORT_LOCATE, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_SEND_COMMAND, + SUPPORT_START, + SUPPORT_STATE, + SUPPORT_STOP, + StateVacuumDevice, +) +from homeassistant.helpers.entity import Entity + +from . import roomba_reported_state +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ATTR_CLEANING_TIME = "cleaning_time" +ATTR_CLEANED_AREA = "cleaned_area" +ATTR_ERROR = "error" +ATTR_POSITION = "position" +ATTR_SOFTWARE_VERSION = "software_version" + +# Commonly supported features +SUPPORT_IROBOT = ( + SUPPORT_BATTERY + | SUPPORT_PAUSE + | SUPPORT_RETURN_HOME + | SUPPORT_SEND_COMMAND + | SUPPORT_START + | SUPPORT_STATE + | SUPPORT_STOP + | SUPPORT_LOCATE +) + +STATE_MAP = { + "": STATE_IDLE, + "charge": STATE_DOCKED, + "hmMidMsn": STATE_CLEANING, # Recharging at the middle of a cycle + "hmPostMsn": STATE_RETURNING, # Cycle finished + "hmUsrDock": STATE_RETURNING, + "pause": STATE_PAUSED, + "run": STATE_CLEANING, + "stop": STATE_IDLE, + "stuck": STATE_ERROR, +} + + +class IRobotEntity(Entity): + """Base class for iRobot Entities.""" + + def __init__(self, roomba, blid): + """Initialize the iRobot handler.""" + self.vacuum = roomba + self._blid = blid + vacuum_state = roomba_reported_state(roomba) + self._name = vacuum_state.get("name") + self._version = vacuum_state.get("softwareVer") + self._sku = vacuum_state.get("sku") + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def robot_unique_id(self): + """Return the uniqueid of the vacuum cleaner.""" + return f"roomba_{self._blid}" + + @property + def unique_id(self): + """Return the uniqueid of the vacuum cleaner.""" + return self.robot_unique_id + + @property + def device_info(self): + """Return the device info of the vacuum cleaner.""" + return { + "identifiers": {(DOMAIN, self.robot_unique_id)}, + "manufacturer": "iRobot", + "name": str(self._name), + "sw_version": self._version, + "model": self._sku, + } + + def register_callback(self): + """Register callback function.""" + self.vacuum.register_on_message_callback(self.on_message) + + def on_message(self, json_data): + """Update state on message change.""" + self.schedule_update_ha_state() + + +class IRobotVacuum(IRobotEntity, StateVacuumDevice): + """Base class for iRobot robots.""" + + def __init__(self, roomba, blid): + """Initialize the iRobot handler.""" + super().__init__(roomba, blid) + self.vacuum_state = roomba_reported_state(roomba) + self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_IROBOT + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + return None + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return [] + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return self.vacuum_state.get("batPct") + + @property + def state(self): + """Return the state of the vacuum cleaner.""" + clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) + cycle = clean_mission_status.get("cycle") + phase = clean_mission_status.get("phase") + try: + state = STATE_MAP[phase] + except KeyError: + return STATE_ERROR + if cycle != "none" and state != STATE_CLEANING and state != STATE_RETURNING: + state = STATE_PAUSED + return state + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return True # Always available, otherwise setup will fail + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + state = self.vacuum_state + + # Roomba software version + software_version = state.get("softwareVer") + + # Error message in plain english + error_msg = "None" + if hasattr(self.vacuum, "error_message"): + error_msg = self.vacuum.error_message + + # Set properties that are to appear in the GUI + state_attrs = {ATTR_SOFTWARE_VERSION: software_version} + + # Only add cleaning time and cleaned area attrs when the vacuum is + # currently on + if self.state == STATE_CLEANING: + # Get clean mission status + mission_state = state.get("cleanMissionStatus", {}) + cleaning_time = mission_state.get("mssnM") + cleaned_area = mission_state.get("sqft") # Imperial + # Convert to m2 if the unit_system is set to metric + if cleaned_area and self.hass.config.units.is_metric: + cleaned_area = round(cleaned_area * 0.0929) + state_attrs[ATTR_CLEANING_TIME] = cleaning_time + state_attrs[ATTR_CLEANED_AREA] = cleaned_area + + # Skip error attr if there is none + if error_msg and error_msg != "None": + state_attrs[ATTR_ERROR] = error_msg + + # Not all Roombas expose position data + # https://github.com/koalazak/dorita980/issues/48 + if self._cap_position: + pos_state = state.get("pose", {}) + position = None + pos_x = pos_state.get("point", {}).get("x") + pos_y = pos_state.get("point", {}).get("y") + theta = pos_state.get("theta") + if all(item is not None for item in [pos_x, pos_y, theta]): + position = f"({pos_x}, {pos_y}, {theta})" + state_attrs[ATTR_POSITION] = position + + return state_attrs + + def on_message(self, json_data): + """Update state on message change.""" + _LOGGER.debug("Got new state from the vacuum: %s", json_data) + self.vacuum_state = self.vacuum.master_state.get("state", {}).get( + "reported", {} + ) + self.schedule_update_ha_state() + + async def async_start(self): + """Start or resume the cleaning task.""" + if self.state == STATE_PAUSED: + await self.hass.async_add_executor_job(self.vacuum.send_command, "resume") + else: + await self.hass.async_add_executor_job(self.vacuum.send_command, "start") + + async def async_stop(self, **kwargs): + """Stop the vacuum cleaner.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") + + async def async_pause(self): + """Pause the cleaning cycle.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + if self.state == STATE_CLEANING: + await self.async_pause() + for _ in range(0, 10): + if self.state == STATE_PAUSED: + break + await asyncio.sleep(1) + await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") + + async def async_locate(self, **kwargs): + """Located vacuum.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "find") + + async def async_send_command(self, command, params=None, **kwargs): + """Send raw command.""" + _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) + await self.hass.async_add_executor_job( + self.vacuum.send_command, command, params + ) + return True diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 6ef71bb9524..a164509bc99 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -3,7 +3,7 @@ "name": "iRobot Roomba", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", - "requirements": ["roombapy==1.5.0"], + "requirements": ["roombapy==1.5.1"], "dependencies": [], - "codeowners": ["@pschmitt", "@cyr-ius"] + "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"] } diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py new file mode 100644 index 00000000000..0a9aec0b608 --- /dev/null +++ b/homeassistant/components/roomba/roomba.py @@ -0,0 +1,95 @@ +"""Class for Roomba devices.""" +import logging + +from homeassistant.components.vacuum import SUPPORT_FAN_SPEED + +from .irobot_base import SUPPORT_IROBOT, IRobotVacuum + +_LOGGER = logging.getLogger(__name__) + +ATTR_BIN_FULL = "bin_full" +ATTR_BIN_PRESENT = "bin_present" + +FAN_SPEED_AUTOMATIC = "Automatic" +FAN_SPEED_ECO = "Eco" +FAN_SPEED_PERFORMANCE = "Performance" +FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] + +# Only Roombas with CarpetBost can set their fanspeed +SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | SUPPORT_FAN_SPEED + + +class RoombaVacuum(IRobotVacuum): + """Basic Roomba robot (without carpet boost).""" + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + state_attrs = super().device_state_attributes + + # Get bin state + bin_raw_state = self.vacuum_state.get("bin", {}) + bin_state = {} + if bin_raw_state.get("present") is not None: + bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present") + if bin_raw_state.get("full") is not None: + bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full") + state_attrs.update(bin_state) + + return state_attrs + + +class RoombaVacuumCarpetBoost(RoombaVacuum): + """Roomba robot with carpet boost.""" + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_ROOMBA_CARPET_BOOST + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + fan_speed = None + carpet_boost = self.vacuum_state.get("carpetBoost") + high_perf = self.vacuum_state.get("vacHigh") + if carpet_boost is not None and high_perf is not None: + if carpet_boost: + fan_speed = FAN_SPEED_AUTOMATIC + elif high_perf: + fan_speed = FAN_SPEED_PERFORMANCE + else: # carpet_boost and high_perf are False + fan_speed = FAN_SPEED_ECO + return fan_speed + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return FAN_SPEEDS + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if fan_speed.capitalize() in FAN_SPEEDS: + fan_speed = fan_speed.capitalize() + _LOGGER.debug("Set fan speed to: %s", fan_speed) + high_perf = None + carpet_boost = None + if fan_speed == FAN_SPEED_AUTOMATIC: + high_perf = False + carpet_boost = True + elif fan_speed == FAN_SPEED_ECO: + high_perf = False + carpet_boost = False + elif fan_speed == FAN_SPEED_PERFORMANCE: + high_perf = True + carpet_boost = False + else: + _LOGGER.error("No such fan speed available: %s", fan_speed) + return + # The set_preference method does only accept string values + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "carpetBoost", str(carpet_boost) + ) + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "vacHigh", str(high_perf) + ) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 2f3a6c53555..3e41e23dfdc 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -2,10 +2,10 @@ import logging from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE -from homeassistant.helpers.entity import Entity from . import roomba_reported_state from .const import BLID, DOMAIN, ROOMBA_SESSION +from .irobot_base import IRobotEntity _LOGGER = logging.getLogger(__name__) @@ -16,21 +16,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): roomba = domain_data[ROOMBA_SESSION] blid = domain_data[BLID] roomba_vac = RoombaBattery(roomba, blid) + roomba_vac.register_callback() async_add_entities([roomba_vac], True) -class RoombaBattery(Entity): +class RoombaBattery(IRobotEntity): """Class to hold Roomba Sensor basic info.""" - def __init__(self, roomba, blid): - """Initialize the sensor object.""" - self.vacuum = roomba - self.vacuum_state = roomba_reported_state(roomba) - self._blid = blid - self._name = self.vacuum_state.get("name") - self._identifier = f"roomba_{self._blid}" - self._battery_level = None - @property def name(self): """Return the name of the sensor.""" @@ -54,23 +46,6 @@ class RoombaBattery(Entity): @property def state(self): """Return the state of the sensor.""" - return self._battery_level - - @property - def device_info(self): - """Return the device info of the vacuum cleaner.""" - return { - "identifiers": {(DOMAIN, self._identifier)}, - "name": str(self._name), - } - - async def async_update(self): - """Return the update info of the vacuum cleaner.""" - # No data, no update - if not self.vacuum.master_state: - _LOGGER.debug("Roomba %s has no data yet. Skip update", self.name) - return - self._battery_level = roomba_reported_state(self.vacuum).get("batPct") - _LOGGER.debug( - "Update battery level status from the vacuum: %s", self._battery_level - ) + battery_level = roomba_reported_state(self.vacuum).get("batPct") + _LOGGER.debug("Update battery level status from the vacuum: %s", battery_level) + return battery_level diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 80dbbc312ea..a68b6349075 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -1,325 +1,32 @@ """Support for Wi-Fi enabled iRobot Roombas.""" import logging -from homeassistant.components.vacuum import ( - SUPPORT_BATTERY, - SUPPORT_FAN_SPEED, - SUPPORT_LOCATE, - SUPPORT_PAUSE, - SUPPORT_RETURN_HOME, - SUPPORT_SEND_COMMAND, - SUPPORT_STATUS, - SUPPORT_STOP, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - VacuumDevice, -) - from . import roomba_reported_state +from .braava import BraavaJet from .const import BLID, DOMAIN, ROOMBA_SESSION +from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost _LOGGER = logging.getLogger(__name__) -ATTR_BIN_FULL = "bin_full" -ATTR_BIN_PRESENT = "bin_present" -ATTR_CLEANING_TIME = "cleaning_time" -ATTR_CLEANED_AREA = "cleaned_area" -ATTR_ERROR = "error" -ATTR_POSITION = "position" -ATTR_SOFTWARE_VERSION = "software_version" - -CAP_POSITION = "position" -CAP_CARPET_BOOST = "carpet_boost" - -FAN_SPEED_AUTOMATIC = "Automatic" -FAN_SPEED_ECO = "Eco" -FAN_SPEED_PERFORMANCE = "Performance" -FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] - - -# Commonly supported features -SUPPORT_ROOMBA = ( - SUPPORT_BATTERY - | SUPPORT_PAUSE - | SUPPORT_RETURN_HOME - | SUPPORT_SEND_COMMAND - | SUPPORT_STATUS - | SUPPORT_STOP - | SUPPORT_TURN_OFF - | SUPPORT_TURN_ON - | SUPPORT_LOCATE -) - -# Only Roombas with CarpetBost can set their fanspeed -SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_ROOMBA | SUPPORT_FAN_SPEED - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the iRobot Roomba vacuum cleaner.""" domain_data = hass.data[DOMAIN][config_entry.entry_id] roomba = domain_data[ROOMBA_SESSION] blid = domain_data[BLID] - roomba_vac = RoombaVacuum(roomba, blid) + + # Get the capabilities of our unit + state = roomba_reported_state(roomba) + capabilities = state.get("cap", {}) + cap_carpet_boost = capabilities.get("carpetBoost") + detected_pad = state.get("detectedPad") + if detected_pad is not None: + constructor = BraavaJet + elif cap_carpet_boost == 1: + constructor = RoombaVacuumCarpetBoost + else: + constructor = RoombaVacuum + + roomba_vac = constructor(roomba, blid) + roomba_vac.register_callback() async_add_entities([roomba_vac], True) - - -class RoombaVacuum(VacuumDevice): - """Representation of a Roomba Vacuum cleaner robot.""" - - def __init__(self, roomba, blid): - """Initialize the Roomba handler.""" - self._available = False - self._battery_level = None - self._capabilities = {} - self._fan_speed = None - self._is_on = False - self._state_attrs = {} - self._status = None - self.vacuum = roomba - self.vacuum_state = roomba_reported_state(roomba) - self._blid = blid - self._name = self.vacuum_state.get("name") - self._version = self.vacuum_state.get("softwareVer") - self._sku = self.vacuum_state.get("sku") - - @property - def unique_id(self): - """Return the uniqueid of the vacuum cleaner.""" - return f"roomba_{self._blid}" - - @property - def device_info(self): - """Return the device info of the vacuum cleaner.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "manufacturer": "iRobot", - "name": str(self._name), - "sw_version": self._version, - "model": self._sku, - } - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - if self._capabilities.get(CAP_CARPET_BOOST): - return SUPPORT_ROOMBA_CARPET_BOOST - return SUPPORT_ROOMBA - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - return self._fan_speed - - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - if self._capabilities.get(CAP_CARPET_BOOST): - return FAN_SPEEDS - - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self._battery_level - - @property - def status(self): - """Return the status of the vacuum cleaner.""" - return self._status - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - - async def async_turn_on(self, **kwargs): - """Turn the vacuum on.""" - await self.hass.async_add_job(self.vacuum.send_command, "start") - self._is_on = True - - async def async_turn_off(self, **kwargs): - """Turn the vacuum off and return to home.""" - await self.async_stop() - await self.async_return_to_base() - - async def async_stop(self, **kwargs): - """Stop the vacuum cleaner.""" - await self.hass.async_add_job(self.vacuum.send_command, "stop") - self._is_on = False - - async def async_resume(self, **kwargs): - """Resume the cleaning cycle.""" - await self.hass.async_add_job(self.vacuum.send_command, "resume") - self._is_on = True - - async def async_pause(self): - """Pause the cleaning cycle.""" - await self.hass.async_add_job(self.vacuum.send_command, "pause") - self._is_on = False - - async def async_start_pause(self, **kwargs): - """Pause the cleaning task or resume it.""" - if self.vacuum_state and self.is_on: # vacuum is running - await self.async_pause() - elif self._status == "Stopped": # vacuum is stopped - await self.async_resume() - else: # vacuum is off - await self.async_turn_on() - - async def async_return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - await self.hass.async_add_job(self.vacuum.send_command, "dock") - self._is_on = False - - async def async_locate(self, **kwargs): - """Located vacuum.""" - await self.hass.async_add_job(self.vacuum.send_command, "find") - - async def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - if fan_speed.capitalize() in FAN_SPEEDS: - fan_speed = fan_speed.capitalize() - _LOGGER.debug("Set fan speed to: %s", fan_speed) - high_perf = None - carpet_boost = None - if fan_speed == FAN_SPEED_AUTOMATIC: - high_perf = False - carpet_boost = True - self._fan_speed = FAN_SPEED_AUTOMATIC - elif fan_speed == FAN_SPEED_ECO: - high_perf = False - carpet_boost = False - self._fan_speed = FAN_SPEED_ECO - elif fan_speed == FAN_SPEED_PERFORMANCE: - high_perf = True - carpet_boost = False - self._fan_speed = FAN_SPEED_PERFORMANCE - else: - _LOGGER.error("No such fan speed available: %s", fan_speed) - return - # The set_preference method does only accept string values - await self.hass.async_add_job( - self.vacuum.set_preference, "carpetBoost", str(carpet_boost) - ) - await self.hass.async_add_job( - self.vacuum.set_preference, "vacHigh", str(high_perf) - ) - - async def async_send_command(self, command, params=None, **kwargs): - """Send raw command.""" - _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) - await self.hass.async_add_job(self.vacuum.send_command, command, params) - return True - - async def async_update(self): - """Fetch state from the device.""" - # No data, no update - if not self.vacuum.master_state: - _LOGGER.debug("Roomba %s has no data yet. Skip update", self.name) - return - state = self.vacuum.master_state.get("state", {}).get("reported", {}) - _LOGGER.debug("Got new state from the vacuum: %s", state) - self.vacuum_state = state - self._available = True - - # Get the capabilities of our unit - capabilities = state.get("cap", {}) - cap_carpet_boost = capabilities.get("carpetBoost") - cap_pos = capabilities.get("pose") - # Store capabilities - self._capabilities = { - CAP_CARPET_BOOST: cap_carpet_boost == 1, - CAP_POSITION: cap_pos == 1, - } - - # Roomba software version - software_version = state.get("softwareVer") - - # Error message in plain english - error_msg = "None" - if hasattr(self.vacuum, "error_message"): - error_msg = self.vacuum.error_message - - self._battery_level = state.get("batPct") - self._status = self.vacuum.current_state - self._is_on = self._status in ["Running"] - - # Set properties that are to appear in the GUI - self._state_attrs = {ATTR_SOFTWARE_VERSION: software_version} - - # Get bin state - bin_state = self._get_bin_state(state) - self._state_attrs.update(bin_state) - - # Only add cleaning time and cleaned area attrs when the vacuum is - # currently on - if self._is_on: - # Get clean mission status - mission_state = state.get("cleanMissionStatus", {}) - cleaning_time = mission_state.get("mssnM") - cleaned_area = mission_state.get("sqft") # Imperial - # Convert to m2 if the unit_system is set to metric - if cleaned_area and self.hass.config.units.is_metric: - cleaned_area = round(cleaned_area * 0.0929) - self._state_attrs[ATTR_CLEANING_TIME] = cleaning_time - self._state_attrs[ATTR_CLEANED_AREA] = cleaned_area - - # Skip error attr if there is none - if error_msg and error_msg != "None": - self._state_attrs[ATTR_ERROR] = error_msg - - # Not all Roombas expose position data - # https://github.com/koalazak/dorita980/issues/48 - if self._capabilities[CAP_POSITION]: - pos_state = state.get("pose", {}) - position = None - pos_x = pos_state.get("point", {}).get("x") - pos_y = pos_state.get("point", {}).get("y") - theta = pos_state.get("theta") - if all(item is not None for item in [pos_x, pos_y, theta]): - position = f"({pos_x}, {pos_y}, {theta})" - self._state_attrs[ATTR_POSITION] = position - - # Fan speed mode (Performance, Automatic or Eco) - # Not all Roombas expose carpet boost - if self._capabilities[CAP_CARPET_BOOST]: - fan_speed = None - carpet_boost = state.get("carpetBoost") - high_perf = state.get("vacHigh") - - if carpet_boost is not None and high_perf is not None: - if carpet_boost: - fan_speed = FAN_SPEED_AUTOMATIC - elif high_perf: - fan_speed = FAN_SPEED_PERFORMANCE - else: # carpet_boost and high_perf are False - fan_speed = FAN_SPEED_ECO - - self._fan_speed = fan_speed - - @staticmethod - def _get_bin_state(state): - bin_raw_state = state.get("bin", {}) - bin_state = {} - - if bin_raw_state.get("present") is not None: - bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present") - - if bin_raw_state.get("full") is not None: - bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full") - - return bin_state diff --git a/requirements_all.txt b/requirements_all.txt index 22cd18a727a..ffcb70903be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1818,7 +1818,7 @@ rocketchat-API==0.6.1 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.5.0 +roombapy==1.5.1 # homeassistant.components.rova rova==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 051d3483278..416c4a448f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -691,7 +691,7 @@ ring_doorbell==0.6.0 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.5.0 +roombapy==1.5.1 # homeassistant.components.yamaha rxv==0.6.0